diff --git a/README.md b/README.md index 24b1666..bbd3d88 100644 --- a/README.md +++ b/README.md @@ -291,11 +291,18 @@ vnoremap ec :!explain-code | Go back | `Escape` | | Select | `Enter` or click | | Navigate | Arrow keys | +| **Scroll content** | Mouse wheel | +| **Scroll up** | Click top of scrollbar | +| **Scroll down** | Click bottom of scrollbar | +| **Page up/down** | Click middle of scrollbar | | **Select text** | `Shift` + mouse drag | | **Copy** | Terminal native (with Shift) | | **Paste** | `Ctrl+Shift+V` | -**Tip:** Hold `Shift` while using mouse for terminal-native text selection. +**Tips:** +- Hold `Shift` while using mouse for terminal-native text selection. +- Code/Prompt editors have DOS-style scrollbars with `▲` and `▼` arrow buttons. +- In step dialogs, use `Tab` to cycle between File, Editor, and Output fields. ## Philosophy diff --git a/src/smarttools/ui_urwid.py b/src/smarttools/ui_urwid.py index f900c55..e6d4d22 100644 --- a/src/smarttools/ui_urwid.py +++ b/src/smarttools/ui_urwid.py @@ -173,6 +173,141 @@ class TabCyclePile(urwid.Pile): return super().keypress(size, key) +class TabPassEdit(urwid.Edit): + """A multiline Edit that passes Tab through for focus cycling instead of inserting tabs.""" + + def keypress(self, size, key): + if key in ('tab', 'shift tab'): + # Pass Tab through to parent for focus cycling + return key + return super().keypress(size, key) + + +class DOSScrollBar(urwid.WidgetWrap): + """A DOS-style scrollbar with arrow buttons at top and bottom.""" + + def __init__(self, widget): + self._wrapped = widget + # Create a columns layout: content on left, scrollbar on right + super().__init__(widget) + + def render(self, size, focus=False): + maxcol, maxrow = size + + # Render the wrapped widget with one less column for scrollbar + content_size = (maxcol - 1, maxrow) + content_canvas = self._wrapped.render(content_size, focus) + + # Build the scrollbar column + scrollbar_chars = [] + + # Up arrow at top + scrollbar_chars.append('▲') + + # Calculate thumb position + if maxrow > 2: + track_height = maxrow - 2 # Minus the two arrow buttons + + # Get scroll position info from wrapped widget + try: + if hasattr(self._wrapped, 'rows_max'): + rows_max = self._wrapped.rows_max(content_size) + scroll_pos = self._wrapped.get_scrollpos(content_size) + else: + rows_max = maxrow + scroll_pos = 0 + + if rows_max > maxrow: + # Calculate thumb position within track + thumb_pos = int((scroll_pos / (rows_max - maxrow)) * (track_height - 1)) + thumb_pos = max(0, min(thumb_pos, track_height - 1)) + else: + thumb_pos = 0 + except (AttributeError, TypeError, ZeroDivisionError): + thumb_pos = 0 + + # Build track with thumb + for i in range(track_height): + if i == thumb_pos: + scrollbar_chars.append('█') # Thumb + else: + scrollbar_chars.append('░') # Track + + # Down arrow at bottom + scrollbar_chars.append('▼') + + # Create scrollbar canvas + scrollbar_text = '\n'.join(scrollbar_chars[:maxrow]) + scrollbar_canvas = urwid.Text(scrollbar_text).render((1,)) + + # Combine canvases + combined = urwid.CanvasJoin([ + (content_canvas, None, focus, content_size[0]), + (scrollbar_canvas, None, False, 1), + ]) + + return combined + + def keypress(self, size, key): + maxcol, maxrow = size + content_size = (maxcol - 1, maxrow) + return self._wrapped.keypress(content_size, key) + + def mouse_event(self, size, event, button, col, row, focus): + maxcol, maxrow = size + content_size = (maxcol - 1, maxrow) + + # Expand clickable area - last 2 columns count as scrollbar + if col >= maxcol - 2: + if button == 1 and event == 'mouse press': + # Top 25% of scrollbar = scroll up + if row < maxrow // 4: + for _ in range(3): + self._wrapped.keypress(content_size, 'up') + self._invalidate() + return True + # Bottom 25% of scrollbar = scroll down + elif row >= maxrow - (maxrow // 4): + for _ in range(3): + self._wrapped.keypress(content_size, 'down') + self._invalidate() + return True + # Middle = page up/down based on which half + elif row < maxrow // 2: + for _ in range(maxrow // 2): + self._wrapped.keypress(content_size, 'up') + self._invalidate() + return True + else: + for _ in range(maxrow // 2): + self._wrapped.keypress(content_size, 'down') + self._invalidate() + return True + + # Handle mouse wheel on scrollbar + if button == 4: # Scroll up + for _ in range(3): + self._wrapped.keypress(content_size, 'up') + self._invalidate() + return True + elif button == 5: # Scroll down + for _ in range(3): + self._wrapped.keypress(content_size, 'down') + self._invalidate() + return True + + return True # Consume other scrollbar clicks + + # Pass to wrapped widget + return self._wrapped.mouse_event(content_size, event, button, col, row, focus) + + def selectable(self): + return self._wrapped.selectable() + + def sizing(self): + return frozenset([urwid.Sizing.BOX]) + + class ToolBuilderLayout(urwid.WidgetWrap): """Custom layout for tool builder that handles Tab cycling across all sections.""" @@ -328,11 +463,20 @@ class Dialog(urwid.WidgetWrap): buttons_row = urwid.Columns([('pack', b) for b in button_widgets], dividechars=2) buttons_centered = urwid.Padding(buttons_row, align='center', width='pack') - # Check if body is a box widget (like ListBox) - is_box_widget = isinstance(body, urwid.ListBox) + # Check if body is a box widget + # ListBox is always a box widget. For Piles with weighted items, + # check if it ONLY supports BOX sizing (not FLOW). + is_box_widget = isinstance(body, (urwid.ListBox, urwid.Scrollable, urwid.ScrollBar)) + if not is_box_widget: + try: + sizing = body.sizing() + # Box widget if it ONLY supports BOX sizing + is_box_widget = sizing == frozenset({urwid.Sizing.BOX}) + except (AttributeError, TypeError): + pass if is_box_widget: - # ListBox is a box widget - use directly with weight + # Box widget - use directly with weight pile = urwid.Pile([ ('pack', title_widget), ('pack', urwid.Divider('─')), @@ -1006,11 +1150,19 @@ class SmartToolsUI: def on_prompt(_): self.close_overlay() - self._add_prompt_dialog() + # Defer dialog opening to avoid overlay rendering issues + if self.loop: + self.loop.set_alarm_in(0, lambda loop, data: self._add_prompt_dialog()) + else: + self._add_prompt_dialog() def on_code(_): self.close_overlay() - self._add_code_dialog() + # Defer dialog opening to avoid overlay rendering issues + if self.loop: + self.loop.set_alarm_in(0, lambda loop, data: self._add_code_dialog()) + else: + self._add_code_dialog() def on_cancel(_): self.close_overlay() @@ -1128,8 +1280,8 @@ class SmartToolsUI: default_file = existing.prompt_file if existing and existing.prompt_file else "prompt.txt" file_edit = urwid.Edit(('label', "File: "), default_file) - # Multiline prompt editor - prompt_edit = urwid.Edit( + # Multiline prompt editor - use TabPassEdit so Tab passes through for navigation + prompt_edit = TabPassEdit( edit_text=existing.prompt if existing else "{input}", multiline=True ) @@ -1211,49 +1363,55 @@ class SmartToolsUI: load_btn = urwid.AttrMap(urwid.Button("Load", on_load), 'button', 'button_focus') - # Prompt editor in a box - prompt_box = urwid.LineBox( - urwid.Filler(urwid.AttrMap(prompt_edit, 'edit', 'edit_focus'), valign='top'), - title="Prompt" - ) + # Prompt editor in a box - use ListBox for proper focus handling and scrolling + # Wrap in DOSScrollBar for DOS-style scrollbar with arrow buttons + prompt_edit_styled = urwid.AttrMap(prompt_edit, 'edit', 'edit_focus') + prompt_walker = urwid.SimpleFocusListWalker([prompt_edit_styled]) + prompt_listbox = urwid.ListBox(prompt_walker) + prompt_scrollbar = DOSScrollBar(prompt_listbox) + prompt_box = urwid.LineBox(prompt_scrollbar, title="Prompt") - body = urwid.Pile([ - vars_text, - urwid.Divider(), - urwid.Columns([ + # Use TabCyclePile so Tab cycles between sections + # Note: All flow widgets must be explicitly wrapped in ('pack', ...) when + # the Pile contains weighted items (urwid 3.x requirement) + body = TabCyclePile([ + ('pack', vars_text), + ('pack', urwid.Divider()), + ('pack', urwid.Columns([ ('pack', urwid.Text(('label', "Provider: "))), ('weight', 1, provider_btn), ('pack', urwid.Text(" ")), ('pack', provider_select_btn), - ]), - urwid.Divider(), - urwid.Columns([ + ])), + ('pack', urwid.Divider()), + ('pack', urwid.Columns([ ('weight', 1, urwid.AttrMap(file_edit, 'edit', 'edit_focus')), ('pack', urwid.Text(" ")), ('pack', load_btn), - ]), - status_text, + ])), + ('pack', status_text), ('weight', 1, prompt_box), - urwid.Divider(), - urwid.AttrMap(output_edit, 'edit', 'edit_focus'), - ]) + ('pack', urwid.Divider()), + ('pack', urwid.AttrMap(output_edit, 'edit', 'edit_focus')), + ], tab_positions=[2, 4, 6, 8]) title = "Edit Prompt Step" if existing else "Add Prompt Step" dialog = Dialog(title, body, [("OK", on_ok), ("Cancel", on_cancel)]) self.show_overlay(dialog, width=70, height=22) def _add_code_dialog(self, existing=None, idx=-1): - """Add/edit code step with multiline editor and file support.""" + """Add/edit code step with multiline editor, file support, and AI auto-adjust.""" from .tool import get_tools_dir + from .providers import load_providers, call_provider # File name input (default based on output_var) default_output_var = existing.output_var if existing else "processed" default_file = existing.code_file if existing and existing.code_file else f"{default_output_var}.py" file_edit = urwid.Edit(('label', "File: "), default_file) - # Multiline code editor (default uses output_var name) + # Multiline code editor - use TabPassEdit so Tab passes through for navigation default_code = existing.code if existing else f"{default_output_var} = input.upper()" - code_edit = urwid.Edit( + code_edit = TabPassEdit( edit_text=default_code, multiline=True ) @@ -1265,6 +1423,126 @@ class SmartToolsUI: status_text = urwid.Text("") + # --- Auto-adjust AI feature --- + providers = load_providers() + provider_names = [p.name for p in providers] + if not provider_names: + provider_names = ["mock"] + selected_ai_provider = [provider_names[0]] + + ai_provider_btn_text = urwid.Text(provider_names[0]) + ai_provider_btn = urwid.AttrMap( + urwid.Padding(ai_provider_btn_text, left=1, right=1), + 'edit', 'edit_focus' + ) + + def show_ai_provider_dropdown(_): + provider_lookup = {p.name: p.description for p in providers} + desc_text = urwid.Text("") + desc_box = urwid.AttrMap(urwid.Padding(desc_text, left=1, right=1), 'label') + + def update_description(name): + desc = provider_lookup.get(name, "") + desc_text.set_text(('label', desc if desc else "No description")) + + def select_provider(name): + def callback(_): + selected_ai_provider[0] = name + ai_provider_btn_text.set_text(name) + self.close_overlay() + return callback + + class DescriptiveButton(urwid.Button): + def __init__(btn_self, name, desc_callback): + super().__init__(name, on_press=select_provider(name)) + btn_self._name = name + btn_self._desc_callback = desc_callback + + def render(btn_self, size, focus=False): + if focus: + btn_self._desc_callback(btn_self._name) + return super().render(size, focus) + + items = [] + for name in provider_names: + short_desc = provider_lookup.get(name, "") + if "|" in short_desc: + short_desc = short_desc.split("|", 1)[1].strip()[:20] + else: + short_desc = short_desc[:20] + label = f"{name:<18} {short_desc}" + btn = DescriptiveButton(name, update_description) + btn.set_label(label) + items.append(urwid.AttrMap(btn, 'button', 'button_focus')) + + update_description(provider_names[0]) + listbox = urwid.ListBox(urwid.SimpleFocusListWalker(items)) + popup_body = urwid.Pile([ + ('weight', 1, listbox), + ('pack', urwid.Divider('─')), + ('pack', desc_box), + ]) + popup = Dialog("Select Provider", popup_body, []) + self.show_overlay(popup, width=50, height=min(len(provider_names) + 6, 16)) + + ai_provider_select_btn = urwid.AttrMap( + urwid.Button("▼", on_press=show_ai_provider_dropdown), + 'button', 'button_focus' + ) + + adjust_instruction = urwid.Edit(('label', ""), "") + + def on_auto_adjust(_): + instruction = adjust_instruction.edit_text.strip() + if not instruction: + status_text.set_text(('error', "Enter an instruction for the AI")) + return + + current_code = code_edit.edit_text.strip() + if not current_code: + status_text.set_text(('error', "No code to adjust")) + return + + provider_name = selected_ai_provider[0] + status_text.set_text(('label', f"Calling {provider_name}...")) + self.refresh() + + prompt = f"""Modify the following Python code according to this instruction: + +INSTRUCTION: {instruction} + +CURRENT CODE: +```python +{current_code} +``` + +AVAILABLE VARIABLES: {', '.join(vars_available)} + +Return ONLY the modified Python code, no explanations or markdown fencing.""" + + result = call_provider(provider_name, prompt) + + if result.success: + new_code = result.text.strip() + if new_code.startswith("```python"): + new_code = new_code[9:] + if new_code.startswith("```"): + new_code = new_code[3:] + if new_code.endswith("```"): + new_code = new_code[:-3] + new_code = new_code.strip() + + code_edit.set_edit_text(new_code) + status_text.set_text(('success', "Code updated by AI")) + else: + status_text.set_text(('error', f"AI error: {result.error[:50]}")) + + auto_adjust_btn = urwid.AttrMap( + urwid.Button("Auto-adjust", on_auto_adjust), + 'button', 'button_focus' + ) + # --- End Auto-adjust feature --- + def do_load(): """Actually load code from file.""" filename = file_edit.edit_text.strip() @@ -1291,7 +1569,6 @@ class SmartToolsUI: status_text.set_text(('error', f"File not found: {filename}")) return - # Show confirmation dialog def on_yes(_): self.close_overlay() do_load() @@ -1310,7 +1587,6 @@ class SmartToolsUI: output_var = output_edit.edit_text.strip() or "processed" code_file = file_edit.edit_text.strip() or None - # Validate Python syntax if code: try: ast.parse(code) @@ -1319,7 +1595,6 @@ class SmartToolsUI: status_text.set_text(('error', f"Syntax error{line_info}: {e.msg}")) return - # Auto-save to file if filename is set if code_file: tool_dir = get_tools_dir() / self._current_tool.name tool_dir.mkdir(parents=True, exist_ok=True) @@ -1345,29 +1620,46 @@ class SmartToolsUI: load_btn = urwid.AttrMap(urwid.Button("Load", on_load), 'button', 'button_focus') - # Code editor in a box with scrolling - code_box = urwid.LineBox( - urwid.Filler(urwid.AttrMap(code_edit, 'edit', 'edit_focus'), valign='top'), - title="Code" - ) + # Code editor in a box - use ListBox for proper focus handling and scrolling + # Wrap in DOSScrollBar for DOS-style scrollbar with arrow buttons + code_edit_styled = urwid.AttrMap(code_edit, 'edit', 'edit_focus') + code_walker = urwid.SimpleFocusListWalker([code_edit_styled]) + code_listbox = urwid.ListBox(code_walker) + code_scrollbar = DOSScrollBar(code_listbox) + code_box = urwid.LineBox(code_scrollbar, title="Code") - body = urwid.Pile([ - vars_text, - urwid.Divider(), - urwid.Columns([ + # Auto-adjust row + auto_adjust_row = urwid.Columns([ + ('pack', ai_provider_btn), + ('pack', ai_provider_select_btn), + ('pack', urwid.Text(" ")), + ('weight', 1, urwid.AttrMap(adjust_instruction, 'edit', 'edit_focus')), + ('pack', urwid.Text(" ")), + ('pack', auto_adjust_btn), + ]) + + # Use TabCyclePile so Tab cycles between sections + # Note: All flow widgets must be explicitly wrapped in ('pack', ...) when + # the Pile contains weighted items (urwid 3.x requirement) + body = TabCyclePile([ + ('pack', vars_text), + ('pack', urwid.Divider()), + ('pack', urwid.Columns([ ('weight', 1, urwid.AttrMap(file_edit, 'edit', 'edit_focus')), ('pack', urwid.Text(" ")), ('pack', load_btn), - ]), - status_text, + ])), + ('pack', status_text), ('weight', 1, code_box), - urwid.Divider(), - urwid.AttrMap(output_edit, 'edit', 'edit_focus'), - ]) + ('pack', urwid.Divider()), + ('pack', auto_adjust_row), + ('pack', urwid.Divider()), + ('pack', urwid.AttrMap(output_edit, 'edit', 'edit_focus')), + ], tab_positions=[2, 4, 6, 8]) title = "Edit Code Step" if existing else "Add Code Step" dialog = Dialog(title, body, [("OK", on_ok), ("Cancel", on_cancel)]) - self.show_overlay(dialog, width=70, height=20) + self.show_overlay(dialog, width=70, height=24) def _edit_step_dialog(self, idx): """Show edit/delete step dialog."""