Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 24 additions & 6 deletions src/rovr/action_buttons/sort_order.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,29 @@ def action_descending(self, descending: bool | None = None) -> None:
descending = not state_manager.get_sort_prefs()[1]
self.action_set(state_manager.get_sort_prefs()[0], descending)

def action_toggle_custom_sort(self) -> None:
self.app.query_one(StateManager).toggle_custom_sort()
self.app.file_list.update_file_list(add_to_session=False)
self.update_icon()

def describe_key_chord_action(self, action: str, description: str) -> str:
if action == "sort_order.toggle_custom_sort":
enabled = self.app.query_one(StateManager).custom_sort_enabled
old_desc = description
state = "enable" if enabled else "disable"
nstate = "disable" if enabled else "enable"
description = (
description
.replace("%state", state)
.replace("%nextstate", nstate)
.replace("%State", state.capitalize())
.replace("%Nextstate", nstate.capitalize())
)
return f"{description}" + (
" (currently enabled)" if enabled and old_desc == description else ""
)
return description


class SortOrderPopup(PopupOptionList):
key_contexts = ("sort_menu", "popup_list", "lists")
Expand Down Expand Up @@ -208,15 +231,10 @@ def shortcut(action: str) -> str:
)

def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
state_manager: StateManager = self.app.query_one(StateManager)

if event.option.id == "descending":
self.button.action_descending()
elif event.option.id == "custom_sort":
# Toggle custom sort for this folder
state_manager.toggle_custom_sort()
self.app.file_list.update_file_list(add_to_session=False)
self.button.update_icon()
self.button.action_toggle_custom_sort()
else:
self.button.action_set(cast(SortByOptions, event.option.id))

Expand Down
20 changes: 18 additions & 2 deletions src/rovr/assets/keys.toml
Original file line number Diff line number Diff line change
Expand Up @@ -73,8 +73,24 @@
"o" = { action = "open", desc = "Open selected files" }
"e" = { action = "open_editor", desc = "Open the current file in an editor" }
"shift+f10" = { action = "open_right_click_menu", desc = "Open the context menu" }
"Y" = { action = "copy.open_popup", desc = "Open the copy menu" }
"," = { action = "sort_order.open_popup", desc = "Open the sort menu" }

[file_list.Y]
desc = "Copy"
"y" = { action = "copy.to_rovr", desc = "Copy files to the rovr clipboard" }
"p" = { action = "copy.highlighted", desc = "Copy the highlighted file path" }
"s" = { action = "copy.to_system_clip", desc = "Copy files to the system clipboard" }
"d" = { action = "copy.current_directory", desc = "Copy the current directory path" }

[file_list.","]
desc = "Sort order"
"a" = { action = "sort_order.name", desc = "Sort by name" }
"e" = { action = "sort_order.extension", desc = "Sort by extension" }
"n" = { action = "sort_order.natural", desc = "Sort naturally" }
"s" = { action = "sort_order.size", desc = "Sort by size" }
"c" = { action = "sort_order.created", desc = "Sort by creation time" }
"m" = { action = "sort_order.modified", desc = "Sort by modification time" }
"d" = { action = "sort_order.descending", desc = "Toggle descending order" }
"p" = { action = "sort_order.toggle_custom_sort", desc = "%Nextstate sorting for this path only" }

[pinned_sidebar]
"/" = { action = "focus_search", desc = "Search pinned directories" }
Expand Down
19 changes: 17 additions & 2 deletions src/rovr/assets/presets/sane.toml
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,23 @@
"o" = { action = "open", desc = "Open selected files" }
"ctrl+o" = { action = "open_editor", desc = "Open the current file in an editor" }
"shift+f10" = { action = "open_right_click_menu", desc = "Open the context menu" }
"C" = { action = "copy.open_popup", desc = "Open the copy menu" }
"," = { action = "sort_order.open_popup", desc = "Open the sort menu" }

[file_list.C]
desc = "Copy"
"r" = { action = "copy.to_rovr", desc = "Copy files to the rovr clipboard" }
"p" = { action = "copy.highlighted", desc = "Copy the highlighted file path" }
"s" = { action = "copy.to_system_clip", desc = "Copy files to the system clipboard" }
"u" = { action = "copy.current_directory", desc = "Copy the current directory path" }

[file_list.","]
desc = "Sort order"
"a" = { action = "sort_order.name", desc = "Sort by name" }
"e" = { action = "sort_order.extension", desc = "Sort by extension" }
"n" = { action = "sort_order.natural", desc = "Sort naturally" }
"s" = { action = "sort_order.size", desc = "Sort by size" }
"c" = { action = "sort_order.created", desc = "Sort by creation time" }
"m" = { action = "sort_order.modified", desc = "Sort by modification time" }
"d" = { action = "sort_order.descending", desc = "Toggle descending order" }

[pinned_sidebar]
"ctrl+f" = { action = "focus_search", desc = "Search pinned directories" }
Expand Down
19 changes: 17 additions & 2 deletions src/rovr/assets/presets/vim.toml
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,23 @@
"o" = { action = "open", desc = "Open selected files" }
"e" = { action = "open_editor", desc = "Open the current file in an editor" }
"shift+f10" = { action = "open_right_click_menu", desc = "Open the context menu" }
"Y" = { action = "copy.open_popup", desc = "Open the copy menu" }
"," = { action = "sort_order.open_popup", desc = "Open the sort menu" }

[file_list.Y]
desc = "Copy"
"y" = { action = "copy.to_rovr", desc = "Copy files to the rovr clipboard" }
"c" = { action = "copy.highlighted", desc = "Copy the highlighted file path" }
"s" = { action = "copy.to_system_clip", desc = "Copy files to the system clipboard" }
"d" = { action = "copy.current_directory", desc = "Copy the current directory path" }

[file_list.","]
desc = "Sort order"
"a" = { action = "sort_order.name", desc = "Sort by name" }
"e" = { action = "sort_order.extension", desc = "Sort by extension" }
"n" = { action = "sort_order.natural", desc = "Sort naturally" }
"s" = { action = "sort_order.size", desc = "Sort by size" }
"c" = { action = "sort_order.created", desc = "Sort by creation time" }
"m" = { action = "sort_order.modified", desc = "Sort by modification time" }
"d" = { action = "sort_order.descending", desc = "Toggle descending order" }

[pinned_sidebar]
"/" = { action = "focus_search", desc = "Search pinned directories" }
Expand Down
126 changes: 121 additions & 5 deletions src/rovr/classes/app_mixins.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,17 @@
from importlib import resources
from os import path
from time import perf_counter
from typing import ClassVar, Iterable
from typing import Any, ClassVar, Iterable, cast

from rich.table import Table
from rich.text import Text
from textual import events, on, work
from textual.app import App
from textual.css.errors import StylesheetError
from textual.css.stylesheet import StylesheetParseError
from textual.dom import DOMNode
from textual.geometry import Offset
from textual.widgets import Static
from textual_drivers.dnd import (
DNDDragIn,
DNDDragInOperation,
Expand All @@ -32,6 +34,7 @@
AllowsExistingFiles,
IsValidFilePath,
)
from rovr.classes.type_aliases import KeyBinding, KeyMap
from rovr.core import (
PinnedSidebar,
PinnedSidebarContainer,
Expand Down Expand Up @@ -703,7 +706,79 @@ async def _show_paste_drop(self, event: events.Paste, destination: str) -> None:
)


class KeyChordPopup(Static):
def __init__(self) -> None:
super().__init__(id="key_chord")
self.display = False

def show_chord(
self,
bindings: KeyMap,
default_namespace: DOMNode,
namespaces: dict[str, DOMNode],
) -> None:
columns = (
1
if "-filelist-only" in self.screen.classes
else 2
if "-no-preview" in self.screen.classes
else 3
)
table = Table.grid(expand=True, padding=(0, 1))
for _ in range(columns):
table.add_column(ratio=1)

cells = []
for key, binding in bindings.items():
if key == "desc" or not isinstance(binding, dict):
continue
display_key = f"<{key}>" if "+" in key else key
description = cast(str, binding.get("desc") or binding.get("action") or key)
action = binding.get("action")
namespace = (
namespaces.get(action.partition(".")[0], default_namespace)
if action is not None
else default_namespace
)
describe = getattr(namespace, "describe_key_chord_action", None)
if callable(describe) and action is not None:
description = describe(action, description)
cells.append(
Text.assemble(
(display_key, "bold"),
f" {description}",
overflow="ellipsis",
no_wrap=True,
)
)
for index in range(0, len(cells), columns):
table.add_row(*cells[index : index + columns])

title = bindings.get("desc")
self.border_title = title if isinstance(title, str) else "Key chord"
self.update(table)
self.display = True

def hide_chord(self) -> None:
self.display = False


class KeyHandler:
_key_chord: KeyMap | None = None
_key_chord_namespace: DOMNode | None = None
_key_chord_popup: KeyChordPopup | None = None

def push_screen(
self: App,
screen: Any,
callback: Any = None,
wait_for_dismiss: bool = False,
*,
mode: str | None = None,
) -> Any:
self._cancel_key_chord()
return App.push_screen(self, screen, callback, wait_for_dismiss, mode=mode)

@lru_cache(maxsize=128)
@staticmethod
def shorten_key(key: str) -> str:
Expand All @@ -727,21 +802,54 @@ def shorten_key(key: str) -> str:
async def _check_bindings(self: App, key: str, priority: bool = False) -> bool:
if not self.keys or self.screen.id == "--command-palette":
return await App._check_bindings(self, key, priority)
key = KeyHandler.shorten_key(key)
namespaces = self._key_namespaces()
if priority and self._key_chord is not None:
if key == "escape":
self._cancel_key_chord()
return True
binding = self._key_chord.get(key)
if isinstance(binding, dict):
if "action" not in binding:
self._key_chord = cast(KeyMap, binding)
self._key_chord_popup.show_chord(
self._key_chord, self._key_chord_namespace, namespaces
)
return True
namespace = self._key_chord_namespace
action = cast(KeyBinding, binding)["action"]
self._cancel_key_chord()
if action == "noop":
return True
return namespace is not None and await self.run_action(
action,
default_namespace=namespace,
namespaces=namespaces,
)
self._cancel_key_chord()
Comment on lines +807 to +829

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (bug_risk): A chord remains active when focus or the current screen changes, because _check_bindings only clears it for Escape, an invalid next key, or a completed binding. The next key pressed in the new context is consumed by the stale chord and its action runs against the namespace captured before the focus or screen change.

Triggers: When the user starts a chord and then clicks another widget, opens a modal, changes screens, or otherwise changes focus before pressing the next key.

Suggested fix: Cancel the chord when focus or screen changes, or verify that the stored namespace and active screen still match before dispatching the chord continuation.

return True

if (
priority
and self.focused is not None
and self.focused.check_consume_key(key, KeyHandler.shorten_key(key))
and self.focused.check_consume_key(key, key)
):
return False

namespaces = self._key_namespaces()
contexts = [("global", self)] if priority else self._active_key_contexts()
for context, namespace in contexts:
context = self.keys.get(context, {})
binding = context.get(KeyHandler.shorten_key(key))
binding = context.get(key)
if not isinstance(binding, dict):
continue
action = binding["action"]
if "action" not in binding:
self._key_chord = cast(KeyMap, binding)
self._key_chord_namespace = namespace
self._key_chord_popup = KeyChordPopup()
await self.screen.mount(self._key_chord_popup)
self._key_chord_popup.show_chord(self._key_chord, namespace, namespaces)
return True
action = cast(KeyBinding, binding)["action"]
if action == "noop":
return True
if action is not None and await self.run_action(
Expand All @@ -752,6 +860,14 @@ async def _check_bindings(self: App, key: str, priority: bool = False) -> bool:
return True
return False

def _cancel_key_chord(self: App) -> None:
self._key_chord = None
self._key_chord_namespace = None
if self._key_chord_popup is not None:
self._key_chord_popup.hide_chord()
self._key_chord_popup.remove()
self._key_chord_popup = None

def _active_key_contexts(self: App) -> list[tuple[str, DOMNode]]:
contexts: list[tuple[str, DOMNode]] = []
focused = self.focused
Expand Down
5 changes: 4 additions & 1 deletion src/rovr/classes/type_aliases.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,13 @@ class KeyBinding(TypedDict):
desc: NotRequired[str]


KeyMap: TypeAlias = "dict[str, KeyBinding | KeyMap | str]"


SortByOptions: TypeAlias = Literal[
"name", "size", "modified", "created", "extension", "natural"
]

ShellRunTypes: TypeAlias = Literal["suspend", "background", "orphan"]

KeysConfig: TypeAlias = dict[str, dict[str, KeyBinding]]
KeysConfig: TypeAlias = dict[str, KeyMap]
Loading
Loading