Round 5 batch A (Grok review): games.yaml seed, pending_ipc fix, doc residue, dead passthrough_id.
Grok round 5 found:
1. examples/games.yaml was actually src/main.rs (227 lines of clap
CLI; my round-4 Item 4 commit message was wrong). Replaced with
a real seed YAML. The example shows the env format as
[[KEY, VALUE], ...] (a list of pairs), NOT KEY=*** (serde_yaml
will not parse KEY=*** into Vec<(String, String)>).
3. App.pending_ipc was written by Free-mode buttons but never
drained by the main loop. Replaced with direct self.ipc(...)
calls. Hoisted the closure borrow by collecting verbs into a
Vec<(&str, u32)> and draining after the slots borrow is
released. Dropped the pending_ipc field + App literal init.
8. Doc residue: passthrough / window_match claims still in
README.md, CHANGELOG.md, docs/DESIGN.md. Updated README.md to
point at round-4 Item 6 + Item 3. Added a round-4 section at
the top of CHANGELOG.md. Dropped the duplicate passthrough
bullet in docs/DESIGN.md.
10. Dead passthrough_id helper in hotkey.rs (unused after the
passthrough feature dropped in round-4 Item 6). Dropped the
function + its doc comment.
cargo test 103/103; clippy clean.
This commit is contained in:
parent
24ca3bac0c
commit
5aabf0d36f
12
CHANGELOG.md
12
CHANGELOG.md
@ -1,5 +1,17 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## Round 4 (2026-09-17)
|
||||||
|
- Dropped the `passthrough` skip-list feature (Item 6).
|
||||||
|
- Dropped `WindowMatch` regex matching; replaced with process-tree
|
||||||
|
tracking via `crate::process::pid_is_ancestor` (Item 3).
|
||||||
|
- Renamed default profile from `esdf-team` to `team` (Item 5).
|
||||||
|
- Window layout now supports Managed + Free-arrange modes
|
||||||
|
(Item 1) with `resize-slot` / `move-slot` / `reset-slot` IPC.
|
||||||
|
- Session game-launcher dropdown reads `~/.config/enboxer/games.yaml`
|
||||||
|
via `launch-game` / `list-games` IPC (Item 4).
|
||||||
|
- Keybinds + VFX page replaced with isboxer-style tree UI
|
||||||
|
grouped by category (Item 2).
|
||||||
|
|
||||||
## 0.1.0 — unreleased
|
## 0.1.0 — unreleased
|
||||||
|
|
||||||
- Rust CLI daemon: `enboxer run|press|status|macros|doctor`
|
- Rust CLI daemon: `enboxer run|press|status|macros|doctor`
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
Mapped-key routing and live window crops for **Arch Linux + Hyprland**. Rust rewrite of a Windows multibox controller — the project layout, slot overlay, and key-routing engine follow the same shape, but nothing game-side is touched. Keys and clicks only. Pushes to `https://gitea.nettsi.de/en/enBoxer` over HTTPS only.
|
Mapped-key routing and live window crops for **Arch Linux + Hyprland**. Rust rewrite of a Windows multibox controller — the project layout, slot overlay, and key-routing engine follow the same shape, but nothing game-side is touched. Keys and clicks only. Pushes to `https://gitea.nettsi.de/en/enBoxer` over HTTPS only.
|
||||||
|
|
||||||
You list which hotkeys go where. Everything you do not list stays on the focused primary. A `passthrough` list (the example uses ESDF) leaves movement keys alone. Alt-tab off the team and the desktop is normal.
|
You list which hotkeys go where. Everything you do not list stays on the focused primary. Round-4 (Item 6) removed the `passthrough` skip-list: every mapped hotkey is intercepted. Alt-tab off the team and the desktop is normal.
|
||||||
|
|
||||||
Nothing is loaded into the game. Hyprland delivers keys with `hl.dsp.send_shortcut` / `send_key_state`. You bind macros in WoW yourself.
|
Nothing is loaded into the game. Hyprland delivers keys with `hl.dsp.send_shortcut` / `send_key_state`. You bind macros in WoW yourself.
|
||||||
|
|
||||||
@ -11,7 +11,7 @@ Nothing is loaded into the game. Hyprland delivers keys with `hl.dsp.send_shortc
|
|||||||
Toggle with `Shift+Alt+M` (configurable) or `enboxer mode maps|mirror|off` while the daemon runs. A Hyprland notification shows the new mode.
|
Toggle with `Shift+Alt+M` (configurable) or `enboxer mode maps|mirror|off` while the daemon runs. A Hyprland notification shows the new mode.
|
||||||
|
|
||||||
1. **maps** — only keys you listed under `maps` are intercepted and sent where the map says. Everything else goes to the front window.
|
1. **maps** — only keys you listed under `maps` are intercepted and sent where the map says. Everything else goes to the front window.
|
||||||
2. **mirror** — the front window still gets the real key; the same key is cloned to the other game windows. `passthrough` (e.g. ESDF) is not cloned.
|
2. **mirror** — the front window still gets the real key; the same key is cloned to the other game windows. Mirror cloning applies to every mapped hotkey (round-4 Item 6 dropped the `passthrough` skip-list).
|
||||||
3. **off** — nothing is intercepted. All keys go to the front window. The mode-toggle hotkey still works.
|
3. **off** — nothing is intercepted. All keys go to the front window. The mode-toggle hotkey still works.
|
||||||
|
|
||||||
A **map** is a hotkey plus steps (send a key, send a named `game_bind`, wait) plus a **target**.
|
A **map** is a hotkey plus steps (send a key, send a named `game_bind`, wait) plus a **target**.
|
||||||
@ -123,7 +123,7 @@ The GUI's **Teams** menu is the first thing to use when you have not configured
|
|||||||
| Path | Purpose |
|
| Path | Purpose |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `~/git/enBoxer` | The Rust crate |
|
| `~/git/enBoxer` | The Rust crate |
|
||||||
| `examples/profile.yaml` | Stock profile (ESDF passthrough, main+strip layout, stock loot map, round-robin example) |
|
| `examples/profile.yaml` | Stock profile (`team`, main+strip layout, stock loot map, round-robin example) |
|
||||||
| `examples/hyprland.lua` | Hyprland bind helper (snippet, optional) |
|
| `examples/hyprland.lua` | Hyprland bind helper (snippet, optional) |
|
||||||
| `~/.config/enboxer/profile.yaml` | Active profile (single-profile legacy / no-team fallback) |
|
| `~/.config/enboxer/profile.yaml` | Active profile (single-profile legacy / no-team fallback) |
|
||||||
| `~/.config/enboxer/teams/<slug>.yaml` | One team = one file |
|
| `~/.config/enboxer/teams/<slug>.yaml` | One team = one file |
|
||||||
|
|||||||
@ -23,8 +23,7 @@ Delivery is compositor input to a window address.
|
|||||||
|
|
||||||
1. **Session off** (focus is not a captured slot and not an enBoxer overlay): no binds. Normal desktop.
|
1. **Session off** (focus is not a captured slot and not an enBoxer overlay): no binds. Normal desktop.
|
||||||
2. **Mode `maps` (default):** only `maps[].hotkey` are intercepted. Everything else, including movement, goes to the focused primary.
|
2. **Mode `maps` (default):** only `maps[].hotkey` are intercepted. Everything else, including movement, goes to the focused primary.
|
||||||
3. **`passthrough`:** never intercepted, even if a map lists them. Default `[]`. Put `e s d f` here to keep those letters on the primary.
|
3. **Passthrough:** removed in round-4 Item 6. Every mapped hotkey is intercepted.
|
||||||
4. **`passthrough`:** never intercepted, even if a map lists them. Default `[]`. Put `e s d f` here to keep those letters on the primary.
|
|
||||||
5. **Video FX hover + `pass_through`:** keys and clicks inside the viewer go to the **source slot only**.
|
5. **Video FX hover + `pass_through`:** keys and clicks inside the viewer go to the **source slot only**.
|
||||||
|
|
||||||
A map with `target: others` never delivers to the primary. A map with `target: all` consumes the OS key and re-sends to every slot including primary (no double-tap).
|
A map with `target: others` never delivers to the primary. A map with `target: all` consumes the OS key and re-sends to every slot including primary (no double-tap).
|
||||||
|
|||||||
@ -1,227 +1,30 @@
|
|||||||
use anyhow::Result;
|
# enBoxer game-launcher list (Item 4).
|
||||||
use clap::{Parser, Subcommand};
|
#
|
||||||
use enboxer::macros::print_macros;
|
# The daemon reads this file (default path: ~/.config/enboxer/games.yaml)
|
||||||
use enboxer::profile::{self, Profile};
|
# and exposes the entries over IPC. The GUI's Session page renders the
|
||||||
use enboxer::session;
|
# names as a dropdown; picking one fires `enboxer ipc launch-game NAME`
|
||||||
use std::path::PathBuf;
|
# which spawns the entry and records the child PID into
|
||||||
use tracing_subscriber::EnvFilter;
|
# Session.spawned_pids. refresh_slots then matches the resulting window
|
||||||
|
# via the process-tree walk (Item 3).
|
||||||
|
#
|
||||||
|
# Each entry is a single game:
|
||||||
|
# name: display name in the dropdown
|
||||||
|
# exe: absolute path to the binary
|
||||||
|
# args: list of args (no pre-quoting; use a list, not a string)
|
||||||
|
# env: list of [KEY, VALUE] pairs (NOT KEY=***; serde_yaml needs a list)
|
||||||
|
# cwd: optional working directory
|
||||||
|
# note: optional human-readable note shown in the dropdown
|
||||||
|
#
|
||||||
|
# Example (uncomment + edit to fit your system):
|
||||||
|
|
||||||
#[derive(Parser)]
|
# - name: World of Warcraft (Retail)
|
||||||
#[command(
|
# exe: /usr/bin/wine
|
||||||
name = "enboxer",
|
# args:
|
||||||
version,
|
# - /path/to/drive_c/Program Files/World of Warcraft/_retail_/Wow.exe
|
||||||
about = "Mapped-key routing for WoW on Hyprland"
|
# env:
|
||||||
)]
|
# - - WINEPREFIX
|
||||||
struct Cli {
|
# - /home/en/.wine
|
||||||
#[command(subcommand)]
|
# cwd: /path/to/drive_c/Program Files/World of Warcraft/_retail_
|
||||||
cmd: Option<Cmd>,
|
# note: retail WoW via Wine
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Subcommand)]
|
games: []
|
||||||
enum Cmd {
|
|
||||||
/// Load a profile and route keys while a managed game is focused
|
|
||||||
Run {
|
|
||||||
#[arg(short, long)]
|
|
||||||
config: Option<PathBuf>,
|
|
||||||
},
|
|
||||||
/// Fire a mapped hotkey (daemon must be running)
|
|
||||||
Press {
|
|
||||||
hotkey: String,
|
|
||||||
#[arg(long)]
|
|
||||||
sock: Option<PathBuf>,
|
|
||||||
},
|
|
||||||
Status {
|
|
||||||
#[arg(long)]
|
|
||||||
sock: Option<PathBuf>,
|
|
||||||
},
|
|
||||||
/// Set or cycle routing mode: maps | mirror | off
|
|
||||||
Mode {
|
|
||||||
/// maps, mirror, off, or omit to cycle
|
|
||||||
which: Option<String>,
|
|
||||||
#[arg(long)]
|
|
||||||
sock: Option<PathBuf>,
|
|
||||||
},
|
|
||||||
/// Print the in-game macros / binds to create
|
|
||||||
Macros {
|
|
||||||
#[arg(short, long)]
|
|
||||||
config: Option<PathBuf>,
|
|
||||||
},
|
|
||||||
/// Check Hyprland + grim + profile
|
|
||||||
Doctor {
|
|
||||||
#[arg(short, long)]
|
|
||||||
config: Option<PathBuf>,
|
|
||||||
},
|
|
||||||
/// Control panel (default if you run `enboxer` with no command)
|
|
||||||
Gui,
|
|
||||||
/// Generate and apply the window layout to captured game clients
|
|
||||||
LayoutApply {
|
|
||||||
#[arg(short, long)]
|
|
||||||
config: Option<PathBuf>,
|
|
||||||
},
|
|
||||||
/// Called by Hyprland binds; not for humans
|
|
||||||
Ipc {
|
|
||||||
#[arg(long)]
|
|
||||||
sock: PathBuf,
|
|
||||||
verb: String,
|
|
||||||
#[arg(trailing_var_arg = true)]
|
|
||||||
rest: Vec<String>,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::main]
|
|
||||||
async fn main() -> Result<()> {
|
|
||||||
tracing_subscriber::fmt()
|
|
||||||
.with_env_filter(EnvFilter::from_default_env().add_directive("enboxer=info".parse()?))
|
|
||||||
.init();
|
|
||||||
let cli = Cli::parse();
|
|
||||||
match cli.cmd.unwrap_or(Cmd::Gui) {
|
|
||||||
Cmd::Gui => enboxer::gui::run(),
|
|
||||||
Cmd::LayoutApply { config } => {
|
|
||||||
let path = config.unwrap_or_else(profile::default_config_path);
|
|
||||||
let mut profile = Profile::load(&path)?;
|
|
||||||
let n = enboxer::layout::apply_for_profile(&mut profile).await?;
|
|
||||||
if let Ok(text) = serde_yaml::to_string(&profile) {
|
|
||||||
let _ = std::fs::write(&path, text);
|
|
||||||
}
|
|
||||||
println!("laid out {n} windows");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
Cmd::Run { config } => {
|
|
||||||
let path = config.unwrap_or_else(profile::default_config_path);
|
|
||||||
let profile = Profile::load(&path)?;
|
|
||||||
tracing::info!("profile {} ({} slots)", profile.name, profile.slots);
|
|
||||||
session::run(profile, session::default_sock()).await
|
|
||||||
}
|
|
||||||
Cmd::Press { hotkey, sock } => {
|
|
||||||
let sock = sock.unwrap_or_else(session::default_sock);
|
|
||||||
println!(
|
|
||||||
"{}",
|
|
||||||
session::ipc_send(&sock, &format!("hotkey {hotkey}")).await?
|
|
||||||
);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
Cmd::Status { sock } => {
|
|
||||||
let sock = sock.unwrap_or_else(session::default_sock);
|
|
||||||
println!("{}", session::ipc_send(&sock, "status").await?);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
Cmd::Mode { which, sock } => {
|
|
||||||
let sock = sock.unwrap_or_else(session::default_sock);
|
|
||||||
let line = match which {
|
|
||||||
Some(w) => format!("mode {w}"),
|
|
||||||
None => "mode-cycle".into(),
|
|
||||||
};
|
|
||||||
println!("{}", session::ipc_send(&sock, &line).await?);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
Cmd::Macros { config } => {
|
|
||||||
let path = config.unwrap_or_else(profile::default_config_path);
|
|
||||||
let profile = Profile::load(&path)?;
|
|
||||||
print!("{}", print_macros(&profile));
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
Cmd::Doctor { config } => doctor(config).await,
|
|
||||||
Cmd::Ipc { sock, verb, rest } => {
|
|
||||||
let arg = rest.join(" ");
|
|
||||||
let line = if arg.is_empty() {
|
|
||||||
verb
|
|
||||||
} else {
|
|
||||||
format!("{verb} {arg}")
|
|
||||||
};
|
|
||||||
println!("{}", session::ipc_send(&sock, &line).await?);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn doctor(config: Option<PathBuf>) -> Result<()> {
|
|
||||||
let mut ok = true;
|
|
||||||
match std::env::var("HYPRLAND_INSTANCE_SIGNATURE") {
|
|
||||||
Ok(s) => println!("hyprland session: {s}"),
|
|
||||||
Err(_) => {
|
|
||||||
println!("hyprland session: MISSING (not in Hyprland?)");
|
|
||||||
ok = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
match enboxer::hypr::eval_lua("return 'ok'").await {
|
|
||||||
Ok(s) => println!("hyprctl eval: {s}"),
|
|
||||||
Err(e) => {
|
|
||||||
println!("hyprctl eval: FAIL {e}");
|
|
||||||
ok = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
match tokio::process::Command::new("grim")
|
|
||||||
.arg("-h")
|
|
||||||
.output()
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(_) => println!("grim: present"),
|
|
||||||
Err(_) => {
|
|
||||||
println!("grim: MISSING (Video FX capture needs grim)");
|
|
||||||
ok = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
match tokio::process::Command::new("mpv")
|
|
||||||
.arg("--version")
|
|
||||||
.output()
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(_) => println!("mpv: present"),
|
|
||||||
Err(_) => {
|
|
||||||
println!("mpv: MISSING (Video FX overlay needs mpv)");
|
|
||||||
ok = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
match enboxer::hypr::dispatch_lua(
|
|
||||||
"hl.dsp.send_shortcut({ window = \"class:enboxer-does-not-exist\", mods = \"\", key = \"a\" })",
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(_) => println!("send_shortcut: compositor accepts dispatcher"),
|
|
||||||
Err(e) => {
|
|
||||||
let msg = e.to_string();
|
|
||||||
if msg.contains("window not found") {
|
|
||||||
println!("send_shortcut: compositor accepts dispatcher");
|
|
||||||
} else {
|
|
||||||
println!("send_shortcut: FAIL {e}");
|
|
||||||
ok = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let Some(path) = config.or_else(|| {
|
|
||||||
let p = profile::default_config_path();
|
|
||||||
p.exists().then_some(p)
|
|
||||||
}) {
|
|
||||||
match Profile::load(&path) {
|
|
||||||
Ok(p) => {
|
|
||||||
println!(
|
|
||||||
"profile: {} maps={} vfx={}",
|
|
||||||
path.display(),
|
|
||||||
p.maps.len(),
|
|
||||||
p.video_fx.len()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
println!("profile {}: FAIL {e}", path.display());
|
|
||||||
ok = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
println!(
|
|
||||||
"profile: none at {}",
|
|
||||||
profile::default_config_path().display()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if let Ok(clients) = enboxer::hypr::clients().await {
|
|
||||||
println!("windows: {}", clients.len());
|
|
||||||
for c in clients.iter().take(12) {
|
|
||||||
println!(" {} {} {:?}", c.address, c.class, c.title);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if ok {
|
|
||||||
println!("doctor: ok");
|
|
||||||
Ok(())
|
|
||||||
} else {
|
|
||||||
anyhow::bail!("doctor found problems")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
22
src/gui.rs
22
src/gui.rs
@ -60,9 +60,6 @@ pub fn run() -> Result<()> {
|
|||||||
layout_drag: None,
|
layout_drag: None,
|
||||||
profile_names_list: Vec::new(),
|
profile_names_list: Vec::new(),
|
||||||
teams: AppTeams::default(),
|
teams: AppTeams::default(),
|
||||||
// Item 1: Free-mode buttons push IPC verbs here; the main
|
|
||||||
// loop drains the queue and writes them to the socket.
|
|
||||||
pending_ipc: Vec::new(),
|
|
||||||
selected_node: None,
|
selected_node: None,
|
||||||
};
|
};
|
||||||
eframe::run_native("enBoxer", native, Box::new(|_cc| Ok(Box::new(app))))
|
eframe::run_native("enBoxer", native, Box::new(|_cc| Ok(Box::new(app))))
|
||||||
@ -123,9 +120,6 @@ struct App {
|
|||||||
/// T15 state. The Lutris picker and the team builder are scoped
|
/// T15 state. The Lutris picker and the team builder are scoped
|
||||||
/// here so they never leak onto other pages.
|
/// here so they never leak onto other pages.
|
||||||
teams: AppTeams,
|
teams: AppTeams,
|
||||||
/// Item 1: Free-mode buttons push IPC verbs here; the main
|
|
||||||
/// loop drains the queue and writes them to the socket.
|
|
||||||
pending_ipc: Vec<String>,
|
|
||||||
/// Item 2: which tree node is currently selected.
|
/// Item 2: which tree node is currently selected.
|
||||||
selected_node: Option<SelectedNode>,
|
selected_node: Option<SelectedNode>,
|
||||||
}
|
}
|
||||||
@ -1203,6 +1197,11 @@ impl App {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
self.profile.layout.slots.truncate(self.profile.slots as usize);
|
self.profile.layout.slots.truncate(self.profile.slots as usize);
|
||||||
|
// IPC verbs to fire AFTER the per-slot closures return.
|
||||||
|
// We can't call self.ipc inside the closure because the
|
||||||
|
// iter_mut borrow on self.profile.layout.slots is still
|
||||||
|
// live.
|
||||||
|
let mut pending_verbs: Vec<(&'static str, u32)> = Vec::new();
|
||||||
for (i, s) in self.profile.layout.slots.iter_mut().enumerate() {
|
for (i, s) in self.profile.layout.slots.iter_mut().enumerate() {
|
||||||
let slot = (i + 1) as u32;
|
let slot = (i + 1) as u32;
|
||||||
let mut w_val = s.initial_size.map(|t| t.0 as i32).unwrap_or(s.w);
|
let mut w_val = s.initial_size.map(|t| t.0 as i32).unwrap_or(s.w);
|
||||||
@ -1214,14 +1213,14 @@ impl App {
|
|||||||
if ui.button("Apply size").clicked() {
|
if ui.button("Apply size").clicked() {
|
||||||
s.initial_size = Some((w_val.max(0) as u32, h_val.max(0) as u32));
|
s.initial_size = Some((w_val.max(0) as u32, h_val.max(0) as u32));
|
||||||
s.size_locked = true;
|
s.size_locked = true;
|
||||||
self.pending_ipc.push(format!("resize-slot {}", slot));
|
pending_verbs.push(("resize-slot", slot));
|
||||||
}
|
}
|
||||||
if ui.button("Apply position").clicked() {
|
if ui.button("Apply position").clicked() {
|
||||||
self.pending_ipc.push(format!("move-slot {}", slot));
|
pending_verbs.push(("move-slot", slot));
|
||||||
}
|
}
|
||||||
if ui.button("Reset size").clicked() {
|
if ui.button("Reset size").clicked() {
|
||||||
s.size_locked = false;
|
s.size_locked = false;
|
||||||
self.pending_ipc.push(format!("reset-slot {}", slot));
|
pending_verbs.push(("reset-slot", slot));
|
||||||
}
|
}
|
||||||
if s.size_locked {
|
if s.size_locked {
|
||||||
ui.label("(locked)");
|
ui.label("(locked)");
|
||||||
@ -1230,6 +1229,11 @@ impl App {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
// Drain the verbs with self.ipc now that the slots
|
||||||
|
// borrow is released.
|
||||||
|
for (verb, slot) in pending_verbs {
|
||||||
|
self.ipc(verb, &slot.to_string());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Item 1: managed-mode layout editor (existing wizard).
|
/// Item 1: managed-mode layout editor (existing wizard).
|
||||||
|
|||||||
@ -175,19 +175,6 @@ fn normalize_token(tok: &str) -> Token {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Lowercase letter or canonical combo. Kept as a pure helper for
|
|
||||||
/// hotkey normalisation; the engine-level `passthrough` feature was
|
|
||||||
/// removed but the normalisation utility may still be useful for
|
|
||||||
/// the tree-driven keybind UI.
|
|
||||||
pub fn passthrough_id(raw: &str) -> Result<String> {
|
|
||||||
let p = parse(raw)?;
|
|
||||||
if !p.ctrl && !p.alt && !p.shift && !p.super_key && p.is_letter() {
|
|
||||||
Ok(p.key)
|
|
||||||
} else {
|
|
||||||
Ok(p.canonical())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn type_keys(s: &str) -> Vec<String> {
|
pub fn type_keys(s: &str) -> Vec<String> {
|
||||||
s.chars()
|
s.chars()
|
||||||
.map(|c| match c {
|
.map(|c| match c {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user