如何使用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()
关键功能说明
- 实时无效字符警告:在单个字符输入时直接校验,无效字符会被拦截并显示红色错误文本
- 帮助窗口:按
ctrl+h弹出居中的帮助框,按任意键关闭 - Enter切换焦点:主循环中监听Enter键,在两个输入框之间切换焦点
- Tab补全:按Tab会自动将当前编辑的片段补全为合法数值(比如月份补为两位、日期补为当月最大天数)
- 方向键导航与数值调整:支持左右键切换编辑片段,上下键调整数值,且日期调整会自动适配当月最大天数
- 无效日期高亮:重写
render方法,当输入无效日期且组件获得焦点时,输入框背景变为红色
备注:内容来源于stack exchange,提问作者a.t.
相关产品推荐
相关产品推荐

