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
## 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`.

View File

@@ -95,7 +95,7 @@ actions:
list-zip:
command:
- unzip -l <path>
- read -rp "Tryck Enter för att fortsätta..."
- read -rp "Press Enter to continue..."
move:
command: mv -v <path> <dir>
@@ -174,7 +174,7 @@ actions:
list-zip:
command:
- unzip -l <path>
- 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.

View File

@@ -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 <path>
- read -rp "Tryck Enter för att fortsätta..."
- read -rp "Press Enter to continue..."
copy:
command: cp -v <path> <dir>copy_<name>

View File

@@ -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

View File

@@ -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)