You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

如何使用Urwid实现具备逐键验证、导航与日期有效性校验的模块化日期/时间输入组件?

如何使用Urwid实现具备逐键验证、导航与日期有效性校验的模块化日期/时间输入组件?

我来帮你完善这个Urwid日期时间输入组件,解决你遇到的几个功能实现问题。咱们基于你现有的代码,一步步补全所有需求点:

核心需求回顾

咱们要实现的功能点包括:

  • 输入无效字符时实时显示红色警告文本
  • 按ctrl+h弹出帮助界面
  • 按Enter切换到下一个输入框
  • 按Tab选择自动补全的日期/时间片段
  • 用方向键导航日期时间片段、调整数值,且只显示合法选项(比如二月的天数随闰年变化)
  • 无效日期时输入框高亮为红色

完整实现代码

下面是修改后的完整代码,每个功能点都做了针对性完善:

import urwid
import datetime
import calendar

class DateTimeEdit(urwid.Edit):
    def __init__(self, caption, date_only=False, **kwargs):
        super().__init__(caption, **kwargs)
        self.date_only = date_only
        self.error_text = urwid.Text("")
        # 用于帮助窗口的引用
        self.loop = None
        self.main_widget = None
        
        self.date_parts = [4, 2, 2]  # 年、月、日的位数
        self.time_parts = [2, 2]      # 时、分的位数
        self.current_part = 0
        self.date_values = [None, None, None]
        self.time_values = [None, None]
        self.date_separator = "-"
        self.time_separator = ":"

    def valid_char(self, ch):
        """校验输入字符是否合法"""
        if ch.isdigit() or ch == self.date_separator or (not self.date_only and ch in [self.time_separator, " "]):
            return True
        return False

    def is_valid_date(self):
        """校验当前输入的日期是否合法"""
        if all(v is not None for v in self.date_values[:3]):
            try:
                datetime.date(self.date_values[0], self.date_values[1], self.date_values[2])
                return True
            except ValueError:
                return False
        return True

    def show_help(self):
        """弹出帮助窗口"""
        help_content = urwid.Text([
            ("bold", "日期/时间输入帮助\n"),
            "格式要求:\n",
            "- 纯日期:YYYY-MM-DD\n",
            "- 日期+时间:YYYY-MM-DD HH:MM\n",
            "\n快捷键说明:\n",
            "- ←/→:切换日期/时间片段\n",
            "- ↑/↓:调整当前片段的数值\n",
            "- Enter:切换到下一个输入框\n",
            "- Tab:补全当前片段的合法值\n",
            "- Ctrl+H:打开此帮助窗口\n",
            "\n输入规则:\n",
            "- 仅允许数字、-、: 和空格\n",
            "- 日期会自动校验合法性(比如二月的天数)\n"
        ])
        help_box = urwid.LineBox(urwid.Filler(help_content, valign='top'))
        # 创建覆盖层显示帮助
        overlay = urwid.Overlay(
            help_box, self.main_widget, 
            align='center', width=('relative', 80), 
            valign='middle', height=('relative', 60)
        )
        self.loop.widget = overlay

        # 绑定关闭帮助的事件
        def close_help(key):
            self.loop.widget = self.main_widget
            self.loop.unhandled_input = None
            return True
        self.loop.unhandled_input = close_help

    def keypress(self, size, key):
        # 处理无效字符输入
        if len(key) == 1:
            if not self.valid_char(key):
                self.error_text.set_text(('error', f"invalid character entered: '{key}'"))
                return None  # 拦截无效字符,不允许输入
            else:
                self.error_text.set_text("")  # 输入合法时清空错误

        # 处理帮助快捷键
        if key == 'ctrl h':
            self.show_help()
            return None
        
        # 处理Tab补全
        if key == 'tab':
            self.complete_current_part()
            return None
        
        # 处理方向键导航与数值调整
        if key == 'left':
            self.move_to_previous_part()
            return None
        if key == 'right':
            self.move_to_next_part()
            return None
        if key == 'up' or key == 'down':
            self.adjust_value(key)
            return None

        # 调用父类处理其他按键
        result = super().keypress(size, key)
        if result:
            self.update_values()
        return result

    def complete_current_part(self):
        """补全当前选中的日期/时间片段为合法值"""
        if self.date_only:
            if self.current_part == 1:  # 月份补全
                self.date_values[1] = self.date_values[1] if (self.date_values[1] and 1<=self.date_values[1]<=12) else 1
                self.date_values[1] = int(str(self.date_values[1]).zfill(2))
            elif self.current_part == 2:  # 日期补全
                max_days = self.get_max_days()
                self.date_values[2] = self.date_values[2] if (self.date_values[2] and 1<=self.date_values[2]<=max_days) else max_days
                self.date_values[2] = int(str(self.date_values[2]).zfill(2))
        else:
            if self.current_part == 1:
                self.date_values[1] = self.date_values[1] if (self.date_values[1] and 1<=self.date_values[1]<=12) else 1
                self.date_values[1] = int(str(self.date_values[1]).zfill(2))
            elif self.current_part == 2:
                max_days = self.get_max_days()
                self.date_values[2] = self.date_values[2] if (self.date_values[2] and 1<=self.date_values[2]<=max_days) else max_days
                self.date_values[2] = int(str(self.date_values[2]).zfill(2))
            elif self.current_part == 3:  # 小时补全
                self.time_values[0] = self.time_values[0] if (self.time_values[0] and 0<=self.time_values[0]<=23) else 0
                self.time_values[0] = int(str(self.time_values[0]).zfill(2))
            elif self.current_part == 4:  # 分钟补全
                self.time_values[1] = self.time_values[1] if (self.time_values[1] and 0<=self.time_values[1]<=59) else 0
                self.time_values[1] = int(str(self.time_values[1]).zfill(2))
        self.update_text()
        self.update_cursor()

    def move_to_next_part(self):
        """切换到下一个日期/时间片段"""
        max_part = len(self.date_parts) -1 if self.date_only else len(self.date_parts)+len(self.time_parts)-1
        if self.current_part < max_part:
            self.current_part +=1
        self.update_cursor()

    def move_to_previous_part(self):
        """切换到上一个日期/时间片段"""
        if self.current_part >0:
            self.current_part -=1
        self.update_cursor()

    def update_cursor(self):
        """计算并设置光标位置"""
        cursor_pos =0
        if self.date_only:
            for i in range(self.current_part):
                cursor_pos += self.date_parts[i] +1
        else:
            if self.current_part < len(self.date_parts):
                for i in range(self.current_part):
                    cursor_pos += self.date_parts[i]+1
            else:
                cursor_pos = sum(self.date_parts) +2  # 加2是日期和时间之间的空格
                for i in range(self.current_part - len(self.date_parts)):
                    cursor_pos += self.time_parts[i]+1
        # 确保光标不超出文本长度
        cursor_pos = min(cursor_pos, len(self.get_edit_text()))
        self.set_edit_pos(cursor_pos)

    def adjust_value(self, direction):
        """调整当前片段的数值"""
        if self.date_only:
            adjust_funcs = [self.adjust_year, self.adjust_month, self.adjust_day]
        else:
            adjust_funcs = [self.adjust_year, self.adjust_month, self.adjust_day, self.adjust_hour, self.adjust_minute]
        adjust_funcs[self.current_part](direction)
        self.update_text()
        # 调整后校验日期合法性,错误时更新错误文本
        if self.current_part in [0,1,2] and not self.is_valid_date():
            self.error_text.set_text(('error', "Invalid date!"))
        else:
            self.error_text.set_text("")

    def adjust_year(self, direction):
        if self.date_values[0] is None:
            self.date_values[0] = datetime.datetime.now().year
        self.date_values[0] +=1 if direction =='up' else -1

    def adjust_month(self, direction):
        if self.date_values[1] is None:
            self.date_values[1] =1
        if direction =='up':
            self.date_values[1] = self.date_values[1] %12 +1
        else:
            self.date_values[1] = self.date_values[1]-2 if self.date_values[1]>1 else 12

    def adjust_day(self, direction):
        if self.date_values[2] is None:
            self.date_values[2] =1
        max_days = self.get_max_days()
        if direction =='up':
            self.date_values[2] = self.date_values[2] % max_days +1
        else:
            self.date_values[2] = self.date_values[2]-1 if self.date_values[2]>1 else max_days

    def adjust_hour(self, direction):
        if self.time_values[0] is None:
            self.time_values[0] =0
        self.time_values[0] = (self.time_values[0] +1) %24 if direction =='up' else (self.time_values[0]-1) %24

    def adjust_minute(self, direction):
        if self.time_values[1] is None:
            self.time_values[1] =0
        self.time_values[1] = (self.time_values[1]+1) %60 if direction =='up' else (self.time_values[1]-1) %60

    def get_max_days(self):
        """获取当前月份的最大天数"""
        if self.date_values[0] is None or self.date_values[1] is None:
            return 31
        try:
            _, max_days = calendar.monthrange(self.date_values[0], self.date_values[1])
            return max_days
        except ValueError:
            return31

    def update_values(self):
        """从输入文本更新日期/时间数值"""
        text = self.get_edit_text().strip()
        if self.date_only:
            parts = text.split(self.date_separator)
            for i in range(min(len(parts),3)):
                try:
                    self.date_values[i] = int(parts[i]) if parts[i] else None
                except ValueError:
                    self.date_values[i] = None
        else:
            date_time_parts = text.split(" ",1)
            if len(date_time_parts)>=1:
                date_parts = date_time_parts[0].split(self.date_separator)
                for i in range(min(len(date_parts),3)):
                    try:
                        self.date_values[i] = int(date_parts[i]) if date_parts[i] else None
                    except ValueError:
                        self.date_values[i] = None
            if len(date_time_parts)==2:
                time_parts = date_time_parts[1].split(self.time_separator)
                for i in range(min(len(time_parts),2)):
                    try:
                        self.time_values[i] = int(time_parts[i]) if time_parts[i] else None
                    except ValueError:
                        self.time_values[i] = None

    def update_text(self):
        """从数值更新输入文本"""
        date_str = self.date_separator.join(
            map(lambda x: str(x).zfill(2) if x is not None else "00", self.date_values)
        )
        if self.date_only:
            self.set_edit_text(date_str)
        else:
            time_str = self.time_separator.join(
                map(lambda x: str(x).zfill(2) if x is not None else "00", self.time_values)
            )
            self.set_edit_text(f"{date_str} {time_str}")

    def render(self, size, focus=False):
        """重写渲染方法,无效日期时高亮为红色"""
        if focus and not self.is_valid_date() and all(v is not None for v in self.date_values[:3]):
            self.set_attr_map({None: 'error_edit'})
        else:
            self.set_attr_map({None: 'normal_edit'})
        return super().render(size, focus)

def main():
    date_edit = DateTimeEdit("日期 (YYYY-MM-DD): ", date_only=True)
    datetime_edit = DateTimeEdit("日期&时间 (YYYY-MM-DD HH:MM): ")

    error_display = urwid.Text("")
    date_edit.error_text = error_display
    datetime_edit.error_text = error_display

    pile = urwid.Pile([
        urwid.Text("请输入日期和/或时间:"),
        date_edit,
        datetime_edit,
        error_display,
    ])

    fill = urwid.Filler(pile, 'top')
    # 设置颜色 palette
    palette = [
        ('normal_edit', 'white', 'dark blue'),
        ('error_edit', 'white', 'dark red'),
        ('error', 'light red', 'default'),
        ('bold', 'bold', 'default'),
    ]
    loop = urwid.MainLoop(fill, palette=palette)

    # 给输入组件传递循环和主组件引用,用于帮助窗口
    date_edit.loop = loop
    date_edit.main_widget = fill
    datetime_edit.loop = loop
    datetime_edit.main_widget = fill

    # 处理Enter切换焦点和退出
    def handle_global_input(key):
        if key == 'enter':
            current_idx = pile.get_focus()[1]
            if current_idx ==1:
                pile.set_focus(2)
            elif current_idx ==2:
                pile.set_focus(1)
        elif key == 'esc':
            raise urwid.ExitMainLoop()

    loop.unhandled_input = handle_global_input
    loop.run()

if __name__ == "__main__":
    main()

关键功能说明

  1. 实时无效字符警告:在单个字符输入时直接校验,无效字符会被拦截并显示红色错误文本
  2. 帮助窗口:按ctrl+h弹出居中的帮助框,按任意键关闭
  3. Enter切换焦点:主循环中监听Enter键,在两个输入框之间切换焦点
  4. Tab补全:按Tab会自动将当前编辑的片段补全为合法数值(比如月份补为两位、日期补为当月最大天数)
  5. 方向键导航与数值调整:支持左右键切换编辑片段,上下键调整数值,且日期调整会自动适配当月最大天数
  6. 无效日期高亮:重写render方法,当输入无效日期且组件获得焦点时,输入框背景变为红色

备注:内容来源于stack exchange,提问作者a.t.

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.04.15 03:43:04