Add DOS-style scrollbars and improve dialog navigation
- Add DOSScrollBar class with ▲/▼ arrow buttons for code/prompt editors
- Expand scrollbar click sensitivity (last 2 columns, zone-based scrolling)
- Fix urwid 3.x compatibility: use explicit ('pack', widget) tuples in Piles
- Add TabPassEdit class for proper Tab cycling in multiline editors
- Add TabCyclePile for focus cycling with Tab key in dialogs
- Add auto-adjust AI feature to automatically set output_var from code
- Update README with new UI navigation documentation
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
4c1d3aa4fd
commit
039df4a6a5
|
|
@ -291,11 +291,18 @@ vnoremap <leader>ec :!explain-code<CR>
|
|||
| 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
|
||||
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
Loading…
Reference in New Issue