# 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()