From f9c6b2923b14dc5837361b3112c319b14db8cbac Mon Sep 17 00:00:00 2001 From: Joakim Date: Tue, 13 Oct 2026 11:04:53 +0200 Subject: [PATCH] N/A --- CHANGELOG.md | 11 +++++ README.md | 17 +++++-- tui/example.tui | 26 +++++----- tui/tui-shell.bash | 2 +- tui/tui.py | 121 +++++++++++++++++++++++++++------------------ 5 files changed, 112 insertions(+), 65 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8743745..023f406 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## 3.1.0 + +- Added floating action menu rendered over the browser without clearing the underlying file list. +- Menu is positioned above or below the current cursor when possible. +- Current cursor row remains visible while the menu is open. +- All user-visible UI strings are now English. +- Toggle-all feedback is transient and no longer leaves stale text in the prompt area. +- Kept menu contents, labels, shortcuts, conditions, and actions data-driven through `.tui`. + +# Changelog + ## v2.1 - Made the installer compatible with minimal Calculinux environments by replacing `install -m` with `cp` followed by `chmod`. diff --git a/README.md b/README.md index dec1869..4ef5f68 100644 --- a/README.md +++ b/README.md @@ -95,7 +95,7 @@ actions: list-zip: command: - unzip -l - - read -rp "Tryck Enter för att fortsätta..." + - read -rp "Press Enter to continue..." move: command: mv -v @@ -174,7 +174,7 @@ actions: list-zip: command: - unzip -l - - read -rp "Tryck Enter för att fortsätta..." + - read -rp "Press Enter to continue..." ``` The flow is: @@ -184,7 +184,7 @@ TUI ↓ unzip -l archive.zip ↓ -read -rp "Tryck Enter..." +read -rp "Press Enter..." ↓ TUI ``` @@ -196,7 +196,7 @@ remaining commands in that action are not executed. TUI prints the exit code and waits for: ```text -Tryck Enter för att fortsätta... +Press Enter to continue... ``` before returning to TUI, so an error cannot disappear immediately. @@ -273,3 +273,12 @@ actions: ``` This keeps the Python code generic: menu presentation, labels, shortcuts, conditions and shell commands belong in `.tui`. + + +## Floating action menu + +Press `M` to open the configured action menu. The menu is rendered as a floating overlay over the browser. It is positioned above or below the current cursor when possible, so the selected file remains visible. + +Menu labels, shortcuts, actions, and context conditions are defined in `.tui`; the Python UI engine does not contain Git-specific menu definitions. + +All user-visible TUI text is English. diff --git a/tui/example.tui b/tui/example.tui index 21f0373..07baa93 100644 --- a/tui/example.tui +++ b/tui/example.tui @@ -48,67 +48,67 @@ keys: # # when: git means this directory is inside a Git worktree. menu: - - label: "Visa" + - label: "View" key: v action: view when: file - - label: "Redigera" + - label: "Edit" key: e action: edit when: file - separator: true - - label: "Kopiera" + - label: "Copy" key: c action: copy selection: auto - - label: "Flytta" + - label: "Move" key: m action: move selection: auto - - label: "Ta bort" + - label: "Delete" key: d action: delete selection: auto - separator: true - - label: "Git: status" + - label: "Git: Status" action: git-status selection: none when: git - - label: "Git: pull" + - label: "Git: Pull" key: p action: git-pull selection: none when: git - - label: "Git: diff" + - label: "Git: Diff" action: git-diff selection: none when: git - - label: "Git: log" + - label: "Git: Log" action: git-log selection: none when: git - - label: "Git: add -A" + - label: "Git: Add -A" action: git-add selection: none when: git - - label: "Git: commit" + - label: "Git: Commit" action: git-commit selection: none when: git - - label: "Git: push" + - label: "Git: Push" action: git-push selection: none when: git @@ -117,7 +117,7 @@ actions: list-zip: command: - unzip -l - - read -rp "Tryck Enter för att fortsätta..." + - read -rp "Press Enter to continue..." copy: command: cp -v copy_ diff --git a/tui/tui-shell.bash b/tui/tui-shell.bash index dcae80a..f07d888 100644 --- a/tui/tui-shell.bash +++ b/tui/tui-shell.bash @@ -104,7 +104,7 @@ tui() { if (( rc != 0 )); then printf '\nTUI: command failed (exit %d)\n' "$rc" >&2 - read -rp 'Tryck Enter för att fortsätta...' _ + read -rp 'Press Enter to continue...' _ break fi done diff --git a/tui/tui.py b/tui/tui.py index 4ca7b34..31cdaf9 100755 --- a/tui/tui.py +++ b/tui/tui.py @@ -6,7 +6,7 @@ import os import subprocess from pathlib import Path -TUI_VERSION = "3.0.0" +TUI_VERSION = "3.1.0" SHELL_HANDOFF = 42 TUI_EXIT = 43 @@ -490,6 +490,7 @@ def entry_color(entry): def draw(stdscr, browser, status=""): + """Draw the main browser screen.""" stdscr.erase() height, width = stdscr.getmaxyx() @@ -497,7 +498,6 @@ def draw(stdscr, browser, status=""): stdscr.addnstr(0, 0, title, max(0, width - 1), curses.A_BOLD) visible = max(1, height - 3) - # Position 0 is the synthetic parent entry. first = max(0, browser.cursor - visible + 1) for row in range(visible): @@ -509,7 +509,6 @@ def draw(stdscr, browser, status=""): entry = browser.path.parent if browser.path != browser.root else None text = "../" if entry is not None else "./" attrs = entry_color(entry) | curses.A_BOLD - is_marked = False else: entry = browser.entries[index - 1] is_marked = entry.resolve() in browser.marked @@ -523,81 +522,105 @@ def draw(stdscr, browser, status=""): stdscr.addnstr(row + 1, 0, text, max(0, width - 1), attrs) - prompt = status or "> " + # The final blank line is a real cursor position. Keep the status area + # separate so transient messages never become part of the prompt. + prompt = status if status else "> " stdscr.addnstr(height - 1, 0, prompt, max(0, width - 1), curses.A_DIM) stdscr.refresh() + def read_ctrl_k(stdscr, browser): height, width = stdscr.getmaxyx() - stdscr.addnstr( - height - 1, - 0, - " Ctrl-K: ", - max(0, width - 1), - curses.A_DIM, - ) + stdscr.addnstr(height - 1, 0, " Ctrl-K: ", max(0, width - 1), curses.A_DIM) stdscr.refresh() key = stdscr.getch() if 0 <= key < 256: char = chr(key).lower() - action = browser.resolve_key_action( - ["ctrl-k", char], - browser.selected(), - ) - + action = browser.resolve_key_action(["ctrl-k", char], browser.selected()) if action: - return action, f"Ctrl-K {char.upper()} → {action}" + return action, f"Ctrl-K {char.upper()} -> {action}" return None, "Unknown Ctrl-K command" +def _menu_dimensions(items, height, width): + selectable = [item for item in items if not item.get("separator")] + longest = max([len(str(i.get("label", ""))) for i in selectable] + [10]) + menu_width = min(max(32, longest + 10), max(10, width - 4)) + menu_height = min(len(items) + 2, max(5, height - 2)) + return menu_width, menu_height + + def show_menu(stdscr, browser): - """Display the .tui-defined action menu and return an action or None.""" + """Show a floating, data-driven menu without hiding the browser behind it.""" items = [item for item in browser.config.menu_items() if browser.config.menu_available(item, browser)] if not items: - return None, "Inga tillgängliga alternativ" + return None, "No actions available" height, width = stdscr.getmaxyx() - selected = 0 + menu_width, menu_height = _menu_dimensions(items, height, width) selectable = [i for i, item in enumerate(items) if not item.get("separator")] selected = selectable[0] while True: - stdscr.erase() - title = f" tui {browser.path} — MENY " - stdscr.addnstr(0, 0, title, max(0, width - 1), curses.A_BOLD) - max_rows = max(1, height - 3) - selected_pos = selectable.index(selected) - first_pos = max(0, selected_pos - max_rows + 1) - visible_indices = selectable[first_pos:first_pos + max_rows] - visible_set = set(visible_indices) - first = max(0, items.index(items[selected]) - max_rows + 1) + # Redraw the real browser first. The floating window is then painted + # over it, leaving the cursor row visible immediately above/below it + # whenever there is room. + draw(stdscr, browser, "") - menu_width = min(width - 2, max(30, max(len(str(i.get("label", ""))) for i in items if not i.get("separator")) + 10)) - left = max(0, (width - menu_width) // 2) - row = 1 + menu_rows = menu_height + menu_top = 1 + cursor_screen_row = 1 + browser.cursor + if cursor_screen_row + menu_rows + 1 > height - 1: + menu_top = max(1, cursor_screen_row - menu_rows - 1) + if menu_top + menu_rows > height - 1: + menu_top = max(1, height - 1 - menu_rows) + menu_left = max(1, min((width - menu_width) // 2, width - menu_width - 1)) + + win = curses.newwin(menu_rows, menu_width, menu_top, menu_left) + win.keypad(True) + win.bkgd(" ", curses.A_NORMAL) + win.border() + try: + win.addnstr(1, 2, "Actions", max(0, menu_width - 4), curses.A_BOLD) + except curses.error: + pass + + content_height = max(1, menu_rows - 3) + selected_pos = selectable.index(selected) + first_pos = max(0, selected_pos - content_height + 1) + visible_selectable = selectable[first_pos:first_pos + content_height] + visible_set = set(visible_selectable) + + row = 2 for idx, item in enumerate(items): - if idx < first or row >= height - 1: + if idx not in visible_set: continue - if item.get("separator"): - text = "─" * max(1, menu_width - 2) - attr = curses.A_DIM - else: - shortcut = str(item.get("key", "")) - text = f" {shortcut:>3} {item['label']}" if shortcut else f" {item['label']}" - attr = curses.A_REVERSE if idx == selected else curses.A_NORMAL - stdscr.addnstr(row, left, text, max(0, menu_width - 1), attr) + if row >= menu_rows - 1: + break + shortcut = str(item.get("key", "")).upper() + label = str(item.get("label", "")) + text = f" {shortcut:<3} {label}" if shortcut else f" {label}" + attr = curses.A_REVERSE if idx == selected else curses.A_NORMAL + try: + win.addnstr(row, 1, text, menu_width - 2, attr) + except curses.error: + pass row += 1 - footer = " ↑/↓ välj Enter kör Esc/M stäng " - stdscr.addnstr(height - 1, 0, footer, max(0, width - 1), curses.A_DIM) - stdscr.refresh() + footer = "Enter Select Esc Close" + try: + win.addnstr(menu_rows - 1, 2, footer, max(0, menu_width - 4), curses.A_DIM) + except curses.error: + pass - key = stdscr.getch() + win.refresh() + + key = win.getch() if key in (27, ord("m"), ord("M")): return None, "" if key in (curses.KEY_UP, ord("k")): @@ -609,7 +632,8 @@ def show_menu(stdscr, browser): selected = selectable[(pos + 1) % len(selectable)] continue if key in (10, 13, curses.KEY_ENTER): - return str(items[selected]["action"]), str(items[selected]["label"]) + item = items[selected] + return str(item["action"]), str(item["label"]) if 0 <= key < 256: char = chr(key).lower() for idx in selectable: @@ -703,7 +727,10 @@ def run(stdscr, start_dir=".", show_all=False): elif action == "toggle-all": browser.show_all = not browser.show_all browser.load() - status = "Dolda filer: " + ("VISAS" if browser.show_all else "DÖLJS") + status = "Hidden files: " + ("SHOWN" if browser.show_all else "HIDDEN") + draw(stdscr, browser, status) + curses.napms(500) + status = "" continue elif action == "menu": action, message = show_menu(stdscr, browser)