hendrik/interfaces/tui/menubar.py
2026-08-13 11:54:09 +07:00

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 = " F10:menu " # sementara: petunjuk tombol, bukan nama 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()