Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,8 @@ Please check the [man page](docs/rofi-rbw.1.md) for details.
| `--action` | `-a` | `type` (default), `copy`, `print` | Choose what `rofi-rbw` should do. |
| `--target` | `-t` | `username`, `password`, `notes`, `totp` (or any custom field) | Choose which components of the selected entry are interesting. Can be passed multiple times to type/copy/print several components. Default is `username` and `password`. |
| `--prompt` | `-r` | any string | Define the text of the prompt. |
| `--keybindings` | | | Define custom keybindings in the format `<shortcut>:<action>:<target>`, for example `Alt+x:copy:username`. Multiple keybindings can be concatenated with `,`; multiple targets for one shortcut can be concatenated with `:`. Note that `wofi` and `fuzzel` don't support keybindings. |
| `--menu-keybindings` | | | Define custom keybindings for the target menu in the format `<shortcut>:<action>`, similar to `--keybindings`. Note that `wofi` and `fuzzel` don't support keybindings. |
| `--keybindings` | | | Define custom keybindings in the format `<shortcut>:<action>:<target>`, for example `Alt+x:copy:username`. Multiple keybindings can be concatenated with `,`; multiple targets for one shortcut can be concatenated with `:`. Note that `wofi` and `bemenu` don't support keybindings. |
| `--menu-keybindings` | | | Define custom keybindings for the target menu in the format `<shortcut>:<action>`, similar to `--keybindings`. Note that `wofi` and `bemenu` don't support keybindings. |
| `--no-cache` | | | Disable the automatic frecency cache. It contains sha1-hashes of the selected entries and how often they were used. |
| `--clear-after` | | integer number >= 0 (default is `0`) | Limit the duration in seconds passwords stay in your clipboard (unless overwritten). When set to 0, passwords will be kept indefinitely. |
| `--typing-start-delay` | | delay in milliseconds | Set a delay before the typing starts. `0` by default, but that may result in problems |
Expand Down
8 changes: 8 additions & 0 deletions src/rofi_rbw/argument_parsing.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,14 @@ def parse_arguments() -> argparse.Namespace:
default=None,
help="Choose the selector frontend",
)
parser.add_argument(
"--fuzzel-config",
dest="fuzzel_config",
action="store",
type=str,
default=None,
help="Path to fuzzel configuration file to use as base when injecting keybindings",
)
parser.add_argument(
"--clipboarder",
dest="clipboarder",
Expand Down
3 changes: 3 additions & 0 deletions src/rofi_rbw/rofi_rbw.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from .models.detailed_entry import DetailedEntry
from .models.targets import Target, Targets, TypeTargets
from .rbw import Rbw
from .selector.fuzzel import Fuzzel
from .selector.selector import Selector
from .typer.typer import Key, Typer

Expand All @@ -18,6 +19,8 @@ def __init__(self) -> None:
self.args = parse_arguments()
self.rbw = Rbw()
self.selector = Selector.best_option(self.args.selector)
if isinstance(self.selector, Fuzzel) and self.args.fuzzel_config:
self.selector.config_path = self.args.fuzzel_config
self.typer = Typer.best_option(self.args.typer)
self.clipboarder = Clipboarder.best_option(self.args.clipboarder)
self.active_window = self.typer.get_active_window()
Expand Down
136 changes: 121 additions & 15 deletions src/rofi_rbw/selector/fuzzel.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,22 @@
from subprocess import run
import atexit
import os
import tempfile
from subprocess import PIPE, run

from ..abstractionhelper import is_installed, is_wayland
from ..models.action import Action
from ..models.detailed_entry import DetailedEntry
from ..models.entry import Entry
from ..models.keybinding import Keybinding
from ..models.targets import Target
from ..models.targets import Target, Targets
from .selector import Selector


class Fuzzel(Selector):
def __init__(self, config_path: str | None = None):
self.config_path = config_path
self._generated_configs: dict[int, str] = {}

@staticmethod
def supported() -> bool:
return is_wayland() and is_installed("fuzzel")
Expand All @@ -26,37 +33,136 @@ def show_selection(
show_folders: bool,
keybindings: list[Keybinding],
additional_args: list[str],
) -> tuple[None, Action | None, Entry | None]:
parameters = ["fuzzel", "--dmenu", "-p", prompt, *additional_args]

) -> tuple[list[Target] | None, Action | None, Entry | None]:
config_path = self.__keybinding_config(keybindings)
fuzzel = run(
parameters,
[
"fuzzel", "--dmenu", "--index", "-p", prompt,
*(["--config", config_path] if config_path else []),
*(self.__format_keybindings_message(keybindings) if show_help_message and keybindings else []),
*additional_args,
],
input="\n".join(self._format_entries(entries, show_folders)),
capture_output=True,
stdout=PIPE,
encoding="utf-8",
)

selected = entries[int(fuzzel.stdout.strip())] if fuzzel.stdout.strip() else None
if fuzzel.returncode == 0:
return None, None, self._find_entry(entries, fuzzel.stdout)
return None, None, selected
elif fuzzel.returncode >= 10 and fuzzel.returncode - 10 < len(keybindings):
keybinding = keybindings[fuzzel.returncode - 10]
return keybinding.targets, keybinding.action, selected
else:
return None, Action.CANCEL, None

def select_target(
self,
entry: DetailedEntry,
show_help_message: bool,
keybindings: dict[str, Action],
keybindings: list[Keybinding],
additional_args: list[str],
) -> tuple[list[Target] | None, Action | None]:
parameters = ["fuzzel", "--dmenu", "-p", "Choose target", *additional_args]

config_path = self.__keybinding_config(keybindings)
fuzzel = run(
parameters,
[
"fuzzel", "--dmenu", "-p", "Choose target",
*(["--config", config_path] if config_path else []),
*(self.__format_keybindings_message(keybindings) if show_help_message and keybindings else []),
*additional_args,
],
input="\n".join(self._format_targets_from_entry(entry)),
capture_output=True,
stdout=PIPE,
encoding="utf-8",
)

if fuzzel.returncode == 1:
if fuzzel.returncode == 0:
return self._extract_targets(fuzzel.stdout), None
elif fuzzel.returncode >= 10 and fuzzel.returncode - 10 < len(keybindings):
return self._extract_targets(fuzzel.stdout), keybindings[fuzzel.returncode - 10].action
else:
return None, Action.CANCEL

return self._extract_targets(fuzzel.stdout), None
def __keybinding_config(self, keybindings: list[Keybinding]) -> str | None:
if not keybindings:
return None

# menu and sync actions close and reopen fuzzel, reuse generated config if able
cache_key = id(keybindings)
if cache_key in self._generated_configs:
return self._generated_configs[cache_key]

content = self.__build_config(keybindings)
fd, path = tempfile.mkstemp(suffix=".ini", prefix="rofi-rbw-fuzzel-")
with os.fdopen(fd, "w") as f:
f.write(content)
atexit.register(os.unlink, path)

self._generated_configs[cache_key] = path
return path

def __build_config(self, keybindings: list[Keybinding]) -> str:
lines = []
# base_config` may already use `include`, can't nest `include`s, so we copy+append instead
base_config = self.config_path or self.__find_user_config()
if base_config:
with open(base_config) as f:
lines.append(f.read().rstrip())
lines.append("")

# parity with rofi on empty menus
lines.append("[dmenu]")
lines.append("exit-immediately-if-empty=no")
lines.append("")

lines.append("[key-bindings]")
for index, keybinding in enumerate(keybindings):
lines.append(f"custom-{1 + index}={self.__convert_shortcut(keybinding.shortcut)}")
# clear any remaining custom-* binds, we don't have handlers for them
for index in range(len(keybindings) + 1, 20):
lines.append(f"custom-{index}=none")

return "\n".join(lines)

@staticmethod
def __find_user_config() -> str | None:
# match fuzzel's search order
candidates = []

xdg_config_home = os.environ.get("XDG_CONFIG_HOME", "")
if xdg_config_home.startswith("/"):
candidates.append(os.path.join(xdg_config_home, "fuzzel", "fuzzel.ini"))
elif "HOME" in os.environ:
candidates.append(os.path.join(os.environ["HOME"], ".config", "fuzzel", "fuzzel.ini"))

xdg_config_dirs = os.environ.get("XDG_CONFIG_DIRS", "")
if xdg_config_dirs:
for d in xdg_config_dirs.split(":"):
candidates.append(os.path.join(d, "fuzzel", "fuzzel.ini"))
else:
candidates.append("/etc/xdg/fuzzel/fuzzel.ini")

for path in candidates:
if os.path.exists(path):
return path
return None

@staticmethod
def __convert_shortcut(shortcut: str) -> str:
return shortcut.replace("Alt+", "Mod1+").replace("Super+", "Mod4+")

@staticmethod
def __format_keybindings_message(keybindings: list[Keybinding]):
parts = []
for keybinding in keybindings:
if keybinding.targets and Targets.MENU in keybinding.targets:
label = "Menu"
elif keybinding.action == Action.SYNC:
label = "Sync logins"
elif keybinding.targets:
label = f"{keybinding.action.value.title()} {', '.join([target.raw for target in keybinding.targets])}"
else:
label = keybinding.action.value.title()
parts.append(f"{keybinding.shortcut}: {label}")

return ["--mesg", " | ".join(parts)]