N/A
This commit is contained in:
11
CHANGELOG.md
11
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`.
|
||||
|
||||
|
||||
17
README.md
17
README.md
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
121
tui/tui.py
121
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)
|
||||
|
||||
Reference in New Issue
Block a user