This commit is contained in:
Joakim
2026-10-13 11:04:53 +02:00
parent 6e7b3cb7ed
commit f9c6b2923b
5 changed files with 112 additions and 65 deletions

View File

@@ -1,5 +1,16 @@
# Changelog # 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 ## v2.1
- Made the installer compatible with minimal Calculinux environments by replacing `install -m` with `cp` followed by `chmod`. - Made the installer compatible with minimal Calculinux environments by replacing `install -m` with `cp` followed by `chmod`.

View File

@@ -95,7 +95,7 @@ actions:
list-zip: list-zip:
command: command:
- unzip -l <path> - unzip -l <path>
- read -rp "Tryck Enter för att fortsätta..." - read -rp "Press Enter to continue..."
move: move:
command: mv -v <path> <dir> command: mv -v <path> <dir>
@@ -174,7 +174,7 @@ actions:
list-zip: list-zip:
command: command:
- unzip -l <path> - unzip -l <path>
- read -rp "Tryck Enter för att fortsätta..." - read -rp "Press Enter to continue..."
``` ```
The flow is: The flow is:
@@ -184,7 +184,7 @@ TUI
↓ ↓
unzip -l archive.zip unzip -l archive.zip
↓ ↓
read -rp "Tryck Enter..." read -rp "Press Enter..."
↓ ↓
TUI TUI
``` ```
@@ -196,7 +196,7 @@ remaining commands in that action are not executed. TUI prints the exit code
and waits for: and waits for:
```text ```text
Tryck Enter för att fortsätta... Press Enter to continue...
``` ```
before returning to TUI, so an error cannot disappear immediately. 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`. 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.

View File

@@ -48,67 +48,67 @@ keys:
# #
# when: git means this directory is inside a Git worktree. # when: git means this directory is inside a Git worktree.
menu: menu:
- label: "Visa" - label: "View"
key: v key: v
action: view action: view
when: file when: file
- label: "Redigera" - label: "Edit"
key: e key: e
action: edit action: edit
when: file when: file
- separator: true - separator: true
- label: "Kopiera" - label: "Copy"
key: c key: c
action: copy action: copy
selection: auto selection: auto
- label: "Flytta" - label: "Move"
key: m key: m
action: move action: move
selection: auto selection: auto
- label: "Ta bort" - label: "Delete"
key: d key: d
action: delete action: delete
selection: auto selection: auto
- separator: true - separator: true
- label: "Git: status" - label: "Git: Status"
action: git-status action: git-status
selection: none selection: none
when: git when: git
- label: "Git: pull" - label: "Git: Pull"
key: p key: p
action: git-pull action: git-pull
selection: none selection: none
when: git when: git
- label: "Git: diff" - label: "Git: Diff"
action: git-diff action: git-diff
selection: none selection: none
when: git when: git
- label: "Git: log" - label: "Git: Log"
action: git-log action: git-log
selection: none selection: none
when: git when: git
- label: "Git: add -A" - label: "Git: Add -A"
action: git-add action: git-add
selection: none selection: none
when: git when: git
- label: "Git: commit" - label: "Git: Commit"
action: git-commit action: git-commit
selection: none selection: none
when: git when: git
- label: "Git: push" - label: "Git: Push"
action: git-push action: git-push
selection: none selection: none
when: git when: git
@@ -117,7 +117,7 @@ actions:
list-zip: list-zip:
command: command:
- unzip -l <path> - unzip -l <path>
- read -rp "Tryck Enter för att fortsätta..." - read -rp "Press Enter to continue..."
copy: copy:
command: cp -v <path> <dir>copy_<name> command: cp -v <path> <dir>copy_<name>

View File

@@ -104,7 +104,7 @@ tui() {
if (( rc != 0 )); then if (( rc != 0 )); then
printf '\nTUI: command failed (exit %d)\n' "$rc" >&2 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 break
fi fi
done done

View File

@@ -6,7 +6,7 @@ import os
import subprocess import subprocess
from pathlib import Path from pathlib import Path
TUI_VERSION = "3.0.0" TUI_VERSION = "3.1.0"
SHELL_HANDOFF = 42 SHELL_HANDOFF = 42
TUI_EXIT = 43 TUI_EXIT = 43
@@ -490,6 +490,7 @@ def entry_color(entry):
def draw(stdscr, browser, status=""): def draw(stdscr, browser, status=""):
"""Draw the main browser screen."""
stdscr.erase() stdscr.erase()
height, width = stdscr.getmaxyx() 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) stdscr.addnstr(0, 0, title, max(0, width - 1), curses.A_BOLD)
visible = max(1, height - 3) visible = max(1, height - 3)
# Position 0 is the synthetic parent entry.
first = max(0, browser.cursor - visible + 1) first = max(0, browser.cursor - visible + 1)
for row in range(visible): 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 entry = browser.path.parent if browser.path != browser.root else None
text = "../" if entry is not None else "./" text = "../" if entry is not None else "./"
attrs = entry_color(entry) | curses.A_BOLD attrs = entry_color(entry) | curses.A_BOLD
is_marked = False
else: else:
entry = browser.entries[index - 1] entry = browser.entries[index - 1]
is_marked = entry.resolve() in browser.marked 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) 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.addnstr(height - 1, 0, prompt, max(0, width - 1), curses.A_DIM)
stdscr.refresh() stdscr.refresh()
def read_ctrl_k(stdscr, browser): def read_ctrl_k(stdscr, browser):
height, width = stdscr.getmaxyx() height, width = stdscr.getmaxyx()
stdscr.addnstr( stdscr.addnstr(height - 1, 0, " Ctrl-K: ", max(0, width - 1), curses.A_DIM)
height - 1,
0,
" Ctrl-K: ",
max(0, width - 1),
curses.A_DIM,
)
stdscr.refresh() stdscr.refresh()
key = stdscr.getch() key = stdscr.getch()
if 0 <= key < 256: if 0 <= key < 256:
char = chr(key).lower() char = chr(key).lower()
action = browser.resolve_key_action( action = browser.resolve_key_action(["ctrl-k", char], browser.selected())
["ctrl-k", char],
browser.selected(),
)
if action: if action:
return action, f"Ctrl-K {char.upper()} → {action}" return action, f"Ctrl-K {char.upper()} -> {action}"
return None, "Unknown Ctrl-K command" 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): 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() items = [item for item in browser.config.menu_items()
if browser.config.menu_available(item, browser)] if browser.config.menu_available(item, browser)]
if not items: if not items:
return None, "Inga tillgängliga alternativ" return None, "No actions available"
height, width = stdscr.getmaxyx() 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")] selectable = [i for i, item in enumerate(items) if not item.get("separator")]
selected = selectable[0] selected = selectable[0]
while True: while True:
stdscr.erase() # Redraw the real browser first. The floating window is then painted
title = f" tui {browser.path} — MENY " # over it, leaving the cursor row visible immediately above/below it
stdscr.addnstr(0, 0, title, max(0, width - 1), curses.A_BOLD) # whenever there is room.
max_rows = max(1, height - 3) draw(stdscr, browser, "")
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)
menu_width = min(width - 2, max(30, max(len(str(i.get("label", ""))) for i in items if not i.get("separator")) + 10)) menu_rows = menu_height
left = max(0, (width - menu_width) // 2) menu_top = 1
row = 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): for idx, item in enumerate(items):
if idx < first or row >= height - 1: if idx not in visible_set:
continue continue
if item.get("separator"): if row >= menu_rows - 1:
text = "─" * max(1, menu_width - 2) break
attr = curses.A_DIM shortcut = str(item.get("key", "")).upper()
else: label = str(item.get("label", ""))
shortcut = str(item.get("key", "")) text = f" {shortcut:<3} {label}" if shortcut else f" {label}"
text = f" {shortcut:>3} {item['label']}" if shortcut else f" {item['label']}"
attr = curses.A_REVERSE if idx == selected else curses.A_NORMAL attr = curses.A_REVERSE if idx == selected else curses.A_NORMAL
stdscr.addnstr(row, left, text, max(0, menu_width - 1), attr) try:
win.addnstr(row, 1, text, menu_width - 2, attr)
except curses.error:
pass
row += 1 row += 1
footer = " ↑/↓ välj Enter kör Esc/M stäng " footer = "Enter Select Esc Close"
stdscr.addnstr(height - 1, 0, footer, max(0, width - 1), curses.A_DIM) try:
stdscr.refresh() 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")): if key in (27, ord("m"), ord("M")):
return None, "" return None, ""
if key in (curses.KEY_UP, ord("k")): if key in (curses.KEY_UP, ord("k")):
@@ -609,7 +632,8 @@ def show_menu(stdscr, browser):
selected = selectable[(pos + 1) % len(selectable)] selected = selectable[(pos + 1) % len(selectable)]
continue continue
if key in (10, 13, curses.KEY_ENTER): 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: if 0 <= key < 256:
char = chr(key).lower() char = chr(key).lower()
for idx in selectable: for idx in selectable:
@@ -703,7 +727,10 @@ def run(stdscr, start_dir=".", show_all=False):
elif action == "toggle-all": elif action == "toggle-all":
browser.show_all = not browser.show_all browser.show_all = not browser.show_all
browser.load() 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 continue
elif action == "menu": elif action == "menu":
action, message = show_menu(stdscr, browser) action, message = show_menu(stdscr, browser)