Item 2 (Grok round 4): isboxer-style keybind tree UI.

Master: "The configurations for keybinds and video fx cannot be a
preconfigured page like you have done. I need to have the ability to
configure as many or few as I want, the way isboxer solved this was
a tree where I can endless add items, when I click an item I would
be able to configure that one item."

References:
- https://wiki.isboxer.com/Key_mapping
- https://wiki.isboxer.com/Key_broadcasting

Implementation:

src/profile.rs:
  * Map.category: String field added (default "Mapped Keys" via a
    serde default helper). Categories: "Always On", "Combat",
    "Mapped Keys", "Character Sets", "Characters".
  * VideoFx.category: String field added (default "Video FX" via a
    serde default helper).
  * default_map_category + default_vfx_category helpers added.

src/gui.rs:
  * SelectedNode enum: Map(usize) | VideoFx(usize). Drives the
    right-pane detail editor.
  * App struct gets a `selected_node: Option<SelectedNode>` field.
  * page_macros is gone. page_keybinds replaces it.
  * keybind_tree (left pane): CollapsingHeader per category. Items
    listed as selectable_label rows with a Remove button. "Add to
    <category>" button creates a new Map (or VideoFx for the VFX
    branch) with sane defaults.
  * keybind_detail (right pane): when a node is selected, shows
    that nodes existing fields -- for a Map: category dropdown,
    name, hotkey, hold checkbox, and the existing steps editor with
    an Add step button. For a VideoFx: the existing on / name /
    slot / pass_through / source / viewer / fps editors.
  * Page::Macros dispatch renamed from page_macros to page_keybinds.
  * ComboBox::from_id_source replaced with ComboBox::from_label
    (clippy::deprecated fix). DragValue uses .range (not the
    deprecated .clamp_range). Dropped unused crate::macros::print_macros
    import. format!("{}", x) replaced with x.to_string().
  * All existing Map + VideoFx literal construction sites in
    engine.rs + gui.rs got the new category field injected with
    syntactically correct commas.

cargo test 103/103; clippy clean.
This commit is contained in:
en 2026-09-17 07:26:39 +02:00
parent 8cb96c1f25
commit 24ca3bac0c
3 changed files with 239 additions and 9 deletions

View File

@ -311,6 +311,7 @@ mod tests {
maps: vec![ maps: vec![
Map { Map {
name: "bar1".into(), name: "bar1".into(),
category: "Mapped Keys".to_string(),
hotkey: Hotkey("1".into()), hotkey: Hotkey("1".into()),
hold: false, hold: false,
steps: vec![ steps: vec![
@ -330,7 +331,8 @@ mod tests {
release_steps: vec![], release_steps: vec![],
}, },
Map { Map {
// Smart shortcut: one user keypress fires the entire interact // Smart shortcut: one user keypress fires the entire interact,
category: "Mapped Keys".to_string(),
// chain (CTM on -> Alt+J -> sleep walk_delay_ms -> CTM off). // chain (CTM on -> Alt+J -> sleep walk_delay_ms -> CTM off).
name: "loot".into(), name: "loot".into(),
hotkey: Hotkey("Alt+G".into()), hotkey: Hotkey("Alt+G".into()),
@ -356,6 +358,7 @@ mod tests {
// between, conditional branches, etc.). // between, conditional branches, etc.).
Map { Map {
name: "loot_manual".into(), name: "loot_manual".into(),
category: "Mapped Keys".to_string(),
hotkey: Hotkey("Ctrl+Alt+G".into()), hotkey: Hotkey("Ctrl+Alt+G".into()),
hold: false, hold: false,
steps: vec![ steps: vec![
@ -466,6 +469,7 @@ mod tests {
let mut e = sample(); let mut e = sample();
let map = crate::profile::Map { let map = crate::profile::Map {
name: "interact_simple".into(), name: "interact_simple".into(),
category: "Mapped Keys".to_string(),
hotkey: Hotkey("Alt+U".into()), hotkey: Hotkey("Alt+U".into()),
hold: false, hold: false,
steps: vec![crate::profile::Step { steps: vec![crate::profile::Step {
@ -498,6 +502,7 @@ mod tests {
e.profile.interact.walk_delay_ms = 2500; e.profile.interact.walk_delay_ms = 2500;
let map = crate::profile::Map { let map = crate::profile::Map {
name: "interact_short".into(), name: "interact_short".into(),
category: "Mapped Keys".to_string(),
hotkey: Hotkey("Alt+I".into()), hotkey: Hotkey("Alt+I".into()),
hold: false, hold: false,
steps: vec![crate::profile::Step { steps: vec![crate::profile::Step {
@ -535,6 +540,7 @@ mod tests {
e.profile.interact.walk_delay_ms = 9999; e.profile.interact.walk_delay_ms = 9999;
let map = crate::profile::Map { let map = crate::profile::Map {
name: "interact_auto".into(), name: "interact_auto".into(),
category: "Mapped Keys".to_string(),
hotkey: Hotkey("Alt+I".into()), hotkey: Hotkey("Alt+I".into()),
hold: false, hold: false,
steps: vec![crate::profile::Step { steps: vec![crate::profile::Step {
@ -565,6 +571,7 @@ mod tests {
e.profile.interact.walk_delay_ms = 5000; e.profile.interact.walk_delay_ms = 5000;
let map = crate::profile::Map { let map = crate::profile::Map {
name: "interact_hold".into(), name: "interact_hold".into(),
category: "Mapped Keys".to_string(),
hotkey: Hotkey("Alt+I".into()), hotkey: Hotkey("Alt+I".into()),
hold: true, hold: true,
steps: vec![crate::profile::Step { steps: vec![crate::profile::Step {
@ -701,6 +708,7 @@ mod tests {
groups: BTreeMap::new(), groups: BTreeMap::new(),
maps: vec![Map { maps: vec![Map {
name: map_name.into(), name: map_name.into(),
category: "Mapped Keys".to_string(),
hotkey: Hotkey("F8".into()), hotkey: Hotkey("F8".into()),
hold: false, hold: false,
steps: vec![Step { steps: vec![Step {

View File

@ -1,7 +1,6 @@
//! Control panel: menus for profile, routing mode, maps, and live crops. //! Control panel: menus for profile, routing mode, maps, and live crops.
use crate::hotkey::Hotkey; use crate::hotkey::Hotkey;
use crate::macros::print_macros;
use crate::profile::{ use crate::profile::{
Character, default_config_path, Group, LayoutMode, LayoutPreset, Map, Mode, NormRect, Profile, Character, default_config_path, Group, LayoutMode, LayoutPreset, Map, Mode, NormRect, Profile,
Repeater, Step, VideoFx, Repeater, Step, VideoFx,
@ -64,6 +63,7 @@ pub fn run() -> Result<()> {
// Item 1: Free-mode buttons push IPC verbs here; the main // Item 1: Free-mode buttons push IPC verbs here; the main
// loop drains the queue and writes them to the socket. // loop drains the queue and writes them to the socket.
pending_ipc: Vec::new(), pending_ipc: Vec::new(),
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))))
.map_err(|e| anyhow::anyhow!("{e}"))?; .map_err(|e| anyhow::anyhow!("{e}"))?;
@ -96,6 +96,14 @@ fn default_profile() -> Profile {
} }
} }
/// Item 2: which tree node the user has selected in the
/// page_keybinds tree (drives the right-pane detail editor).
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum SelectedNode {
Map(usize),
VideoFx(usize),
}
struct App { struct App {
path: PathBuf, path: PathBuf,
profile: Profile, profile: Profile,
@ -118,6 +126,8 @@ struct App {
/// Item 1: Free-mode buttons push IPC verbs here; the main /// Item 1: Free-mode buttons push IPC verbs here; the main
/// loop drains the queue and writes them to the socket. /// loop drains the queue and writes them to the socket.
pending_ipc: Vec<String>, pending_ipc: Vec<String>,
/// Item 2: which tree node is currently selected.
selected_node: Option<SelectedNode>,
} }
#[derive(Default)] #[derive(Default)]
@ -563,6 +573,7 @@ impl eframe::App for App {
if ui.button("Add crop").clicked() { if ui.button("Add crop").clicked() {
self.profile.video_fx.push(VideoFx { self.profile.video_fx.push(VideoFx {
name: format!("crop{}", self.profile.video_fx.len() + 1), name: format!("crop{}", self.profile.video_fx.len() + 1),
category: "Video FX".to_string(),
enabled: true, enabled: true,
source_slot: 2, source_slot: 2,
source: NormRect { source: NormRect {
@ -635,7 +646,7 @@ impl eframe::App for App {
Page::Layout => self.page_layout(ui), Page::Layout => self.page_layout(ui),
Page::Maps => self.page_maps(ui), Page::Maps => self.page_maps(ui),
Page::Video => self.page_video(ui), Page::Video => self.page_video(ui),
Page::Macros => self.page_macros(ui), Page::Macros => self.page_keybinds(ui),
Page::Teams => self.page_teams(ui), Page::Teams => self.page_teams(ui),
}); });
} }
@ -786,6 +797,7 @@ impl App {
if ui.button("Add map").clicked() { if ui.button("Add map").clicked() {
self.profile.maps.push(Map { self.profile.maps.push(Map {
name: format!("map{}", self.profile.maps.len() + 1), name: format!("map{}", self.profile.maps.len() + 1),
category: "Mapped Keys".to_string(),
hotkey: Hotkey("1".into()), hotkey: Hotkey("1".into()),
hold: false, hold: false,
steps: vec![Step { steps: vec![Step {
@ -865,6 +877,7 @@ impl App {
if ui.button("Add crop").clicked() { if ui.button("Add crop").clicked() {
self.profile.video_fx.push(VideoFx { self.profile.video_fx.push(VideoFx {
name: format!("crop{}", self.profile.video_fx.len() + 1), name: format!("crop{}", self.profile.video_fx.len() + 1),
category: "Video FX".to_string(),
enabled: true, enabled: true,
source_slot: 2.min(self.profile.slots), source_slot: 2.min(self.profile.slots),
source: NormRect { source: NormRect {
@ -1304,15 +1317,206 @@ impl App {
} }
} }
fn page_macros(&mut self, ui: &mut egui::Ui) { fn page_keybinds(&mut self, ui: &mut egui::Ui) {
ui.heading("In-game macros"); ui.heading("Key bindings + video FX");
ui.label("Bind these on every account. enBoxer only sends keystrokes."); ui.label("Tree editor: click an item to edit it. Add as many or as few as you want per category.");
let text = print_macros(&self.profile); ui.horizontal(|ui| {
egui::ScrollArea::vertical().show(ui, |ui| { // LEFT: tree
ui.monospace(text); egui::SidePanel::left("tree")
.resizable(true)
.default_width(260.0)
.show_inside(ui, |ui| {
self.keybind_tree(ui);
});
// RIGHT: detail editor
egui::CentralPanel::default().show_inside(ui, |ui| {
self.keybind_detail(ui);
});
}); });
} }
/// Item 2: tree (left pane). Categories as CollapsingHeaders,
/// items as selectable buttons. Add/Remove buttons per
/// category.
fn keybind_tree(&mut self, ui: &mut egui::Ui) {
let map_cats = ["Always On", "Combat", "Mapped Keys", "Character Sets", "Characters"];
for cat in map_cats {
let _header = egui::CollapsingHeader::new(cat)
.default_open(cat == "Mapped Keys")
.show(ui, |ui| {
// List items in this category.
let mut remove_idx = None;
for (i, m) in self.profile.maps.iter().enumerate() {
if m.category != cat {
continue;
}
let selected = self.selected_node == Some(SelectedNode::Map(i));
if ui.selectable_label(selected, format!("{} -> {}", m.name, m.hotkey.0)).clicked() {
self.selected_node = Some(SelectedNode::Map(i));
}
ui.horizontal(|ui| {
if ui.button("Remove").clicked() {
remove_idx = Some(i);
}
});
}
if let Some(idx) = remove_idx {
self.profile.maps.remove(idx);
// Re-resolve selected_node so we don't
// hold a stale index.
match self.selected_node {
Some(SelectedNode::Map(j)) if j == idx => self.selected_node = None,
Some(SelectedNode::Map(j)) if j > idx => {
self.selected_node = Some(SelectedNode::Map(j - 1));
}
_ => {}
}
}
if ui.button(format!("+ Add to {}", cat)).clicked() {
let n = self.profile.maps.len();
self.profile.maps.push(crate::profile::Map {
category: cat.to_string(),
name: format!("new_map_{}", n + 1),
hotkey: crate::hotkey::Hotkey("".into()),
hold: false,
steps: Vec::new(),
release_steps: Vec::new(),
});
self.selected_node = Some(SelectedNode::Map(n));
}
});
}
// Video FX branch.
egui::CollapsingHeader::new("Video FX")
.default_open(false)
.show(ui, |ui| {
let mut remove_idx = None;
for (i, fx) in self.profile.video_fx.iter().enumerate() {
let selected = self.selected_node == Some(SelectedNode::VideoFx(i));
if ui.selectable_label(selected, fx.name.to_string()).clicked() {
self.selected_node = Some(SelectedNode::VideoFx(i));
}
if ui.button("Remove").clicked() {
remove_idx = Some(i);
}
}
if let Some(idx) = remove_idx {
self.profile.video_fx.remove(idx);
match self.selected_node {
Some(SelectedNode::VideoFx(j)) if j == idx => self.selected_node = None,
Some(SelectedNode::VideoFx(j)) if j > idx => {
self.selected_node = Some(SelectedNode::VideoFx(j - 1));
}
_ => {}
}
}
if ui.button("+ Add Video FX").clicked() {
let n = self.profile.video_fx.len();
self.profile.video_fx.push(crate::profile::VideoFx {
category: "Video FX".to_string(),
name: format!("new_fx_{}", n + 1),
..Default::default()
});
self.selected_node = Some(SelectedNode::VideoFx(n));
}
});
}
/// Item 2: detail editor (right pane). Renders whichever
/// item the user selected in the tree.
fn keybind_detail(&mut self, ui: &mut egui::Ui) {
match self.selected_node {
Some(SelectedNode::Map(i)) => {
if let Some(m) = self.profile.maps.get_mut(i) {
ui.heading(format!("Map #{}", i + 1));
ui.horizontal(|ui| {
ui.label("category");
egui::ComboBox::from_label(format!("map_cat_{}", i))
.selected_text(m.category.clone())
.show_ui(ui, |ui| {
for cat in ["Always On", "Combat", "Mapped Keys", "Character Sets", "Characters"] {
ui.selectable_value(&mut m.category, cat.to_string(), cat);
}
});
});
ui.horizontal(|ui| {
ui.label("name");
ui.text_edit_singleline(&mut m.name);
ui.label("hotkey");
ui.text_edit_singleline(&mut m.hotkey.0);
ui.checkbox(&mut m.hold, "hold");
});
ui.separator();
ui.label("Steps (executed in order on press)");
let mut step_remove = None;
for (si, s) in m.steps.iter_mut().enumerate() {
ui.horizontal(|ui| {
ui.label(format!("{}", si + 1));
ui.label("bind");
let mut bind_str = s.bind.clone().unwrap_or_default();
if ui.text_edit_singleline(&mut bind_str).changed() {
s.bind = if bind_str.is_empty() { None } else { Some(bind_str) };
}
ui.label("key");
let mut key_str = s.key.clone().unwrap_or_default();
if ui.text_edit_singleline(&mut key_str).changed() {
s.key = if key_str.is_empty() { None } else { Some(key_str) };
}
ui.label("target");
ui.text_edit_singleline(&mut s.target);
if ui.button("x").clicked() {
step_remove = Some(si);
}
});
}
if let Some(si) = step_remove {
m.steps.remove(si);
}
if ui.button("+ Add step").clicked() {
m.steps.push(crate::profile::Step {
bind: None,
key: None,
delay_ms: None,
target: "all".to_string(),
});
}
}
}
Some(SelectedNode::VideoFx(i)) => {
if let Some(fx) = self.profile.video_fx.get_mut(i) {
ui.heading(format!("Video FX #{}", i + 1));
ui.horizontal(|ui| {
ui.checkbox(&mut fx.enabled, "on");
ui.label("name");
ui.text_edit_singleline(&mut fx.name);
ui.label("from slot");
ui.add(egui::DragValue::new(&mut fx.source_slot).range(1..=16));
ui.checkbox(&mut fx.pass_through, "clicks+keys through");
});
ui.label("source x y w h");
ui.horizontal(|ui| {
ui.add(egui::DragValue::new(&mut fx.source.x));
ui.add(egui::DragValue::new(&mut fx.source.y));
ui.add(egui::DragValue::new(&mut fx.source.w));
ui.add(egui::DragValue::new(&mut fx.source.h));
});
ui.label("viewer x y w h");
ui.horizontal(|ui| {
ui.add(egui::DragValue::new(&mut fx.viewer.x));
ui.add(egui::DragValue::new(&mut fx.viewer.y));
ui.add(egui::DragValue::new(&mut fx.viewer.w));
ui.add(egui::DragValue::new(&mut fx.viewer.h));
});
ui.label("fps");
ui.add(egui::DragValue::new(&mut fx.fps).range(1..=60));
}
}
None => {
ui.label("Click an item on the left to edit it.");
}
}
}
fn page_teams(&mut self, ui: &mut egui::Ui) { fn page_teams(&mut self, ui: &mut egui::Ui) {
ui.heading("Teams / Lutris"); ui.heading("Teams / Lutris");
ui.label("A team bundles a Profile with a Lutris game launcher and per-character wine overrides. Each team is one YAML in ~/.config/enboxer/teams/<slug>.yaml."); ui.label("A team bundles a Profile with a Lutris game launcher and per-character wine overrides. Each team is one YAML in ~/.config/enboxer/teams/<slug>.yaml.");

View File

@ -179,6 +179,11 @@ pub struct Group {
#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Map { pub struct Map {
/// Item 2: tree category. One of: "Always On", "Combat",
/// "Mapped Keys", "Character Sets", "Characters". Defaults
/// to "Mapped Keys".
#[serde(default = "default_map_category")]
pub category: String,
pub name: String, pub name: String,
pub hotkey: Hotkey, pub hotkey: Hotkey,
#[serde(default)] #[serde(default)]
@ -207,6 +212,9 @@ fn default_target() -> String {
#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct VideoFx { pub struct VideoFx {
/// Item 2: tree category. Always "Video FX" for now.
#[serde(default = "default_vfx_category")]
pub category: String,
pub name: String, pub name: String,
#[serde(default = "default_true")] #[serde(default = "default_true")]
pub enabled: bool, pub enabled: bool,
@ -324,6 +332,16 @@ fn default_one() -> f64 {
1.0 1.0
} }
/// Item 2: default category for new Map entries.
fn default_map_category() -> String {
"Mapped Keys".to_string()
}
/// Item 2: default category for new VideoFx entries.
fn default_vfx_category() -> String {
"Video FX".to_string()
}
impl NormRect { impl NormRect {
/// Values > 1 are pixels inside the window; otherwise fractions 0..=1 of the window. /// Values > 1 are pixels inside the window; otherwise fractions 0..=1 of the window.