ui(console): center menu in a bordered box with better spacing

Both renderers now draw the menu inside a centered box with:
- Rounded corners (modern) / box-drawing (VT220)
- Title centered inside the box top
- Section separators as horizontal lines within the box
- Selected item highlighted across the full box width
- Vertical and horizontal centering on screen

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-15 02:31:54 +00:00
parent a3aa2a7608
commit 7866194e65
2 changed files with 103 additions and 29 deletions

View File

@@ -167,17 +167,42 @@ class CursesRenderer(BaseRenderer):
def draw_menu(self, items, selected_index=0, title=''): def draw_menu(self, items, selected_index=0, title=''):
h, w = self.get_size() h, w = self.get_size()
start_row = 3
# Calculate menu dimensions for centering
item_count = len(items)
# Find widest label for box sizing
max_label = 0
for num, label in items:
if num != "---" and num != "\u2500":
max_label = max(max_label, len(f" {num}. {label} "))
box_w = max(max_label + 8, 44)
box_w = min(box_w, w - 4)
box_h = item_count + 4 # top/bottom border + title + blank line
if title: if title:
self._safe_addstr(start_row, 2, title, self._attr("title")) box_h += 2
start_row += 2
visible = h - start_row - 3 # leave room for footer # Center the box vertically and horizontally
if visible < 1: start_row = max((h - box_h) // 2 - 1, 2)
return start_col = max((w - box_w) // 2, 1)
# Scrolling offset # Draw box
self.draw_box(start_row, start_col, box_h, box_w, "")
row = start_row + 1
if title:
# Title centered inside the box
title_col = start_col + max((box_w - len(title)) // 2, 2)
self._safe_addstr(row, title_col, title,
self._attr("title"))
row += 1
self._hline(row, start_col + 1, box_w - 2)
row += 1
# Menu items inside the box
inner_left = start_col + 3
inner_w = box_w - 6
visible = box_h - (row - start_row) - 1
offset = 0 offset = 0
if selected_index >= visible: if selected_index >= visible:
offset = selected_index - visible + 1 offset = selected_index - visible + 1
@@ -188,19 +213,20 @@ class CursesRenderer(BaseRenderer):
break break
if idx < offset: if idx < offset:
continue continue
row = start_row + drawn
# Separator # Separator
if num == "\u2500" or num == "---": if num == "\u2500" or num == "---":
self._hline(row, 2, w - 4) self._hline(row, start_col + 1, box_w - 2)
row += 1
drawn += 1 drawn += 1
continue continue
marker = "\u25b8 " if idx == selected_index else " " marker = "\u25b8 " if idx == selected_index else " "
text = f"{marker}{num}. {label}" text = f"{marker}{num}. {label}"
style = "highlight" if idx == selected_index else "normal" style = "highlight" if idx == selected_index else "normal"
self._safe_addstr(row, 2, pad_right(text, w - 4), self._safe_addstr(row, inner_left, pad_right(text, inner_w),
self._attr(style)) self._attr(style))
row += 1
drawn += 1 drawn += 1
def draw_table(self, headers, rows, widths, page_info=None, def draw_table(self, headers, rows, widths, page_info=None,

View File

@@ -258,46 +258,94 @@ class TextualRenderer(BaseRenderer):
def draw_menu(self, items, selected_index=0, title=''): def draw_menu(self, items, selected_index=0, title=''):
h, w = self.get_size() h, w = self.get_size()
visible_lines = h - 6
# Calculate menu box dimensions
max_label = 0
for num, label in items:
if num != "---" and num != "\u2500":
max_label = max(max_label, len(f" {num}. {label} "))
box_inner = max(max_label + 4, 40)
box_inner = min(box_inner, w - 8)
# Vertical centering: blank lines before menu
item_count = len(items)
menu_height = item_count + (3 if title else 0)
top_pad = max((h - menu_height - 6) // 2 - 1, 0)
for _ in range(top_pad):
self._add_line(Text(""))
# Horizontal padding
pad_left = max((w - box_inner - 4) // 2, 2)
pad_str = " " * pad_left
# Box top border
self._add_line(Text(
f"{pad_str}\u256d{'' * (box_inner + 2)}\u256e",
style="blue",
))
if title: if title:
self._add_line(Text(f" {title}", style="bold white")) # Title centered inside box
self._add_line(Text("")) title_pad = max((box_inner - len(title)) // 2, 0)
visible_lines -= 2 line = Text()
line.append(f"{pad_str}\u2502 ", style="blue")
line.append(" " * title_pad, style="")
line.append(title, style="bold white")
line.append(
" " * (box_inner - title_pad - len(title)),
style="",
)
line.append(" \u2502", style="blue")
self._add_line(line)
# Separator inside box
self._add_line(Text(
f"{pad_str}\u251c{'' * (box_inner + 2)}\u2524",
style="blue",
))
if visible_lines < 1: visible = h - self._line_count - 4
return if visible < 1:
visible = item_count
offset = 0 offset = 0
if selected_index >= visible_lines: if selected_index >= visible:
offset = selected_index - visible_lines + 1 offset = selected_index - visible + 1
drawn = 0 drawn = 0
for idx, (num, label) in enumerate(items): for idx, (num, label) in enumerate(items):
if drawn >= visible_lines: if drawn >= visible:
break break
if idx < offset: if idx < offset:
continue continue
if num == "\u2500" or num == "---": if num == "\u2500" or num == "---":
self._add_line( self._add_line(Text(
Text(" " + "" * (w - 4), style="dim blue") f"{pad_str}\u251c{'' * (box_inner + 2)}\u2524",
) style="dim blue",
))
drawn += 1 drawn += 1
continue continue
marker = "\u25b8 " if idx == selected_index else " " marker = "\u25b8 " if idx == selected_index else " "
entry = f"{marker}{num}. {label}"
entry = pad_right(entry, box_inner)
line = Text()
line.append(f"{pad_str}\u2502 ", style="blue")
if idx == selected_index: if idx == selected_index:
entry = pad_right(f"{marker}{num}. {label}", w - 4) line.append(f" {entry}", style="bold white on rgb(30,60,120)")
self._add_line(
Text(f" {entry}", style="bold white on rgb(30,60,120)")
)
else: else:
self._add_line( line.append(f" {entry}", style="white")
Text(f" {marker}{num}. {label}", style="white") line.append(" \u2502", style="blue")
) self._add_line(line)
drawn += 1 drawn += 1
# Box bottom border
self._add_line(Text(
f"{pad_str}\u2570{'' * (box_inner + 2)}\u256f",
style="blue",
))
def draw_table(self, headers, rows, widths, page_info=None, def draw_table(self, headers, rows, widths, page_info=None,
selected_row=-1): selected_row=-1):
h, w = self.get_size() h, w = self.get_size()