293 lines
8.8 KiB
Python
293 lines
8.8 KiB
Python
# menubar.py — Menu bar TUI: dispatch shortcut & interaksi menu.
|
|
#
|
|
# Bertanggung jawab atas:
|
|
# * dispatch shortcut langsung (Ctrl+N, F2, F4, F6, Ctrl+C, Ctrl+Enter, Esc)
|
|
# * aktivasi menu bar (F10) dan mnemonic (Alt+S/A/M/W)
|
|
# * navigasi dropdown menu (panah / Enter / Esc / huruf mnemonic)
|
|
# * menggambar menu bar dan dropdown-nya
|
|
# Semua definisi aksi/menu tinggal di actions.py — module ini hanya memakainya.
|
|
|
|
import curses
|
|
|
|
from . import theme
|
|
from .actions import ACTIONS, MENUS, perform
|
|
from .keycodes import (
|
|
alt_key_char,
|
|
KEY_ESC,
|
|
)
|
|
|
|
# Peta mnemonic menu (huruf kecil) → index menu
|
|
ALT_MENU = {m["mnemonic"].lower(): i for i, m in enumerate(MENUS)}
|
|
|
|
|
|
# ---------------------------------------------------- shortcut global (F10 dll) ---
|
|
def _global_bindings():
|
|
for action_id, spec in ACTIONS.items():
|
|
yield spec["key"], action_id, spec["enabled"]
|
|
|
|
|
|
def dispatch_global_key(app, stdscr, key) -> bool:
|
|
# Rutekan shortcut langsung. Mengembalikan True jika key dikonsumsi
|
|
# (sekalipun aksi disabled → no-op), False jika bukan shortcut global.
|
|
|
|
# F10: aktifkan/tutup menu bar, langsung buka dropdown menu pertama
|
|
# supaya ada feedback visual seketika.
|
|
if key == curses.KEY_F10:
|
|
if app.menu_active:
|
|
deactivate_menu(app)
|
|
else:
|
|
activate_menu(app, 0, open_dropdown=True)
|
|
return True
|
|
|
|
for shortcut, action_id, enabled in _global_bindings():
|
|
if shortcut == key:
|
|
if enabled(app):
|
|
perform(app, stdscr, action_id)
|
|
return True
|
|
return False
|
|
|
|
|
|
# ----------------------------------------------------------- mnemonic Alt+ ---
|
|
def alt_key_to_menu(key: int) -> int | None:
|
|
ch = alt_key_char(key)
|
|
if ch is None:
|
|
return None
|
|
return ALT_MENU.get(ch)
|
|
|
|
|
|
def handle_alt_menu_key(app, stdscr, key) -> bool:
|
|
# Alt+S / Alt+A / Alt+M / Alt+W → fokus menu terkait + buka dropdown.
|
|
menu_idx = alt_key_to_menu(key)
|
|
if menu_idx is None:
|
|
return False
|
|
activate_menu(app, menu_idx, open_dropdown=True)
|
|
return True
|
|
|
|
|
|
# -------------------------------------------------------------- state helper ---
|
|
def _selectable_indices(menu) -> list[int]:
|
|
return [i for i, item in enumerate(menu["items"]) if item != "sep"]
|
|
|
|
|
|
def _first_usable(app, menu) -> int:
|
|
sel = _selectable_indices(menu)
|
|
for i in sel:
|
|
if menu["items"][i]["enabled"](app):
|
|
return i
|
|
return sel[0] if sel else -1
|
|
|
|
|
|
def activate_menu(app, menu_idx: int, open_dropdown: bool = False):
|
|
app.menu_active = True
|
|
app.active_menu = menu_idx
|
|
app.menu_sel = _first_usable(app, MENUS[menu_idx])
|
|
app.menu_open = open_dropdown
|
|
|
|
|
|
def deactivate_menu(app):
|
|
app.menu_active = False
|
|
app.menu_open = False
|
|
|
|
|
|
# ------------------------------------------------------------- menubar nav ---
|
|
def _move_horizontal(app, delta: int):
|
|
total = len(MENUS)
|
|
app.active_menu = (app.active_menu + delta) % total
|
|
app.menu_open = False
|
|
app.menu_sel = _first_usable(app, MENUS[app.active_menu])
|
|
|
|
|
|
def _move_vertical(app, delta: int):
|
|
if not app.menu_open:
|
|
app.menu_open = True
|
|
return
|
|
sel = _selectable_indices(MENUS[app.active_menu])
|
|
if not sel:
|
|
return
|
|
pos = sel.index(app.menu_sel) if app.menu_sel in sel else 0
|
|
pos = (pos + delta) % len(sel)
|
|
app.menu_sel = sel[pos]
|
|
|
|
|
|
def _item_by_mnemonic(app, key: int) -> int | None:
|
|
ch = chr(key).lower() if 32 <= key <= 126 else None
|
|
if not ch:
|
|
return None
|
|
menu = MENUS[app.active_menu]
|
|
for i, item in enumerate(menu["items"]):
|
|
if item != "sep" and item.get("mnemonic"):
|
|
if item["mnemonic"].lower() == ch:
|
|
return i
|
|
return None
|
|
|
|
|
|
def _select_current(app, stdscr):
|
|
menu = MENUS[app.active_menu]
|
|
item = menu["items"][app.menu_sel]
|
|
if item == "sep" or not item["enabled"](app):
|
|
return
|
|
deactivate_menu(app)
|
|
perform(app, stdscr, item["id"])
|
|
|
|
|
|
def handle_menubar_key(app, stdscr, key):
|
|
# Dipanggil hanya ketika app.menu_active == True.
|
|
alt_idx = alt_key_to_menu(key)
|
|
if alt_idx is not None:
|
|
activate_menu(app, alt_idx, open_dropdown=True)
|
|
return
|
|
|
|
if not app.menu_open:
|
|
if key in (curses.KEY_LEFT,):
|
|
_move_horizontal(app, -1)
|
|
elif key in (curses.KEY_RIGHT,):
|
|
_move_horizontal(app, 1)
|
|
elif key in (curses.KEY_DOWN, curses.KEY_UP, curses.KEY_ENTER, 10, 13):
|
|
app.menu_open = True
|
|
app.menu_sel = _first_usable(app, MENUS[app.active_menu])
|
|
elif key in (KEY_ESC, curses.KEY_F10):
|
|
deactivate_menu(app)
|
|
else:
|
|
# Huruf mnemonic → lompat ke menu tersebut (mis. ESC dulu, lalu 'M').
|
|
ch = chr(key).lower() if 32 <= key <= 126 else None
|
|
if ch is not None and ch in ALT_MENU:
|
|
activate_menu(app, ALT_MENU[ch], open_dropdown=True)
|
|
return
|
|
|
|
# Dropdown terbuka
|
|
if key in (curses.KEY_UP,):
|
|
_move_vertical(app, -1)
|
|
elif key in (curses.KEY_DOWN,):
|
|
_move_vertical(app, 1)
|
|
elif key in (curses.KEY_LEFT,):
|
|
_move_horizontal(app, -1)
|
|
elif key in (curses.KEY_RIGHT,):
|
|
_move_horizontal(app, 1)
|
|
elif key in (curses.KEY_ENTER, 10, 13):
|
|
_select_current(app, stdscr)
|
|
elif key == KEY_ESC:
|
|
app.menu_open = False
|
|
elif key == curses.KEY_F10:
|
|
deactivate_menu(app)
|
|
else:
|
|
item_idx = _item_by_mnemonic(app, key)
|
|
if item_idx is not None:
|
|
app.menu_sel = item_idx
|
|
_select_current(app, stdscr)
|
|
|
|
|
|
# ---------------------------------------------------------------- rendering ---
|
|
def _menu_x(menu_idx: int) -> int:
|
|
# Posisi kolom awal menu ke-menu_idx pada baris menubar.
|
|
x = 1
|
|
for idx, menu in enumerate(MENUS):
|
|
if idx == menu_idx:
|
|
return x
|
|
x += len(f" [{menu['mnemonic']}]{menu['name']} ")
|
|
return 1
|
|
|
|
|
|
def draw_menubar(app, stdscr):
|
|
y = 0
|
|
base_attr = curses.color_pair(theme.C_MENU) | curses.A_BOLD
|
|
active_attr = curses.color_pair(theme.C_MENU_ACTIVE)
|
|
|
|
try:
|
|
stdscr.addstr(y, 0, " " * app.w, base_attr)
|
|
except curses.error:
|
|
pass
|
|
|
|
x = 1
|
|
for idx, menu in enumerate(MENUS):
|
|
is_active = app.menu_active and idx == app.active_menu
|
|
attr = active_attr if is_active else base_attr
|
|
text = f" [{menu['mnemonic']}]{menu['name']} "
|
|
try:
|
|
stdscr.addstr(y, x, text, attr)
|
|
except curses.error:
|
|
pass
|
|
x += len(text)
|
|
|
|
model = f" {app.llm.model} "
|
|
try:
|
|
stdscr.addstr(y, max(0, app.w - len(model)), model, base_attr)
|
|
except curses.error:
|
|
pass
|
|
|
|
|
|
def draw_menu_dropdown(app, stdscr):
|
|
menu = MENUS[app.active_menu]
|
|
items = menu["items"]
|
|
|
|
max_label = 0
|
|
for item in items:
|
|
if item != "sep":
|
|
max_label = max(max_label, len(item["label"]) + len(item.get("shortcut", "")))
|
|
bw = min(max(max_label + 10, 18), app.w - 2)
|
|
bh = min(len(items) + 2, app.h - 2)
|
|
if bh < 3:
|
|
return
|
|
|
|
px = min(_menu_x(app.active_menu), max(0, app.w - bw - 1))
|
|
py = 1
|
|
|
|
win = curses.newwin(bh, bw, py, px)
|
|
try:
|
|
win.box()
|
|
except curses.error:
|
|
pass
|
|
|
|
for i, item in enumerate(items):
|
|
y = 1 + i
|
|
if y >= bh - 1:
|
|
break
|
|
|
|
if item == "sep":
|
|
try:
|
|
win.addstr(y, 1, "\u2500" * max(0, bw - 2),
|
|
curses.color_pair(theme.C_MENU_DISABLED))
|
|
except curses.error:
|
|
pass
|
|
continue
|
|
|
|
selected = (i == app.menu_sel)
|
|
enabled = item["enabled"](app)
|
|
if selected and enabled:
|
|
attr = curses.color_pair(theme.C_MENU_SEL)
|
|
elif selected:
|
|
attr = curses.color_pair(theme.C_MENU_SEL) | curses.A_DIM
|
|
elif enabled:
|
|
attr = curses.color_pair(theme.C_MENU_DROPDOWN)
|
|
else:
|
|
attr = curses.color_pair(theme.C_MENU_DISABLED)
|
|
|
|
label = item["label"]
|
|
shortcut = item.get("shortcut", "")
|
|
mnemonic = item.get("mnemonic")
|
|
pos = label.upper().find(mnemonic.upper()) if mnemonic else -1
|
|
|
|
try:
|
|
x = 1
|
|
if pos > 0:
|
|
win.addstr(y, x, label[:pos], attr)
|
|
x += pos
|
|
if pos >= 0:
|
|
win.addstr(y, x, label[pos], attr | curses.A_UNDERLINE)
|
|
x += 1
|
|
if pos >= 0:
|
|
win.addstr(y, x, label[pos + 1:], attr)
|
|
x += len(label) - pos - 1
|
|
else:
|
|
win.addstr(y, x, label, attr)
|
|
x += len(label)
|
|
|
|
# Shortcut rata kanan
|
|
sx = bw - len(shortcut) - 2
|
|
if sx > x:
|
|
win.addstr(y, x, " " * (sx - x), attr)
|
|
win.addstr(y, sx, shortcut, attr)
|
|
except curses.error:
|
|
pass
|
|
|
|
win.refresh()
|