diff --git a/src/engine.rs b/src/engine.rs index 53190bc..d7e56ab 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -311,6 +311,7 @@ mod tests { maps: vec![ Map { name: "bar1".into(), + category: "Mapped Keys".to_string(), hotkey: Hotkey("1".into()), hold: false, steps: vec![ @@ -330,7 +331,8 @@ mod tests { release_steps: vec![], }, 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). name: "loot".into(), hotkey: Hotkey("Alt+G".into()), @@ -356,6 +358,7 @@ mod tests { // between, conditional branches, etc.). Map { name: "loot_manual".into(), + category: "Mapped Keys".to_string(), hotkey: Hotkey("Ctrl+Alt+G".into()), hold: false, steps: vec![ @@ -466,6 +469,7 @@ mod tests { let mut e = sample(); let map = crate::profile::Map { name: "interact_simple".into(), + category: "Mapped Keys".to_string(), hotkey: Hotkey("Alt+U".into()), hold: false, steps: vec![crate::profile::Step { @@ -498,6 +502,7 @@ mod tests { e.profile.interact.walk_delay_ms = 2500; let map = crate::profile::Map { name: "interact_short".into(), + category: "Mapped Keys".to_string(), hotkey: Hotkey("Alt+I".into()), hold: false, steps: vec![crate::profile::Step { @@ -535,6 +540,7 @@ mod tests { e.profile.interact.walk_delay_ms = 9999; let map = crate::profile::Map { name: "interact_auto".into(), + category: "Mapped Keys".to_string(), hotkey: Hotkey("Alt+I".into()), hold: false, steps: vec![crate::profile::Step { @@ -565,6 +571,7 @@ mod tests { e.profile.interact.walk_delay_ms = 5000; let map = crate::profile::Map { name: "interact_hold".into(), + category: "Mapped Keys".to_string(), hotkey: Hotkey("Alt+I".into()), hold: true, steps: vec![crate::profile::Step { @@ -701,6 +708,7 @@ mod tests { groups: BTreeMap::new(), maps: vec![Map { name: map_name.into(), + category: "Mapped Keys".to_string(), hotkey: Hotkey("F8".into()), hold: false, steps: vec![Step { diff --git a/src/gui.rs b/src/gui.rs index cc15938..e571ba8 100644 --- a/src/gui.rs +++ b/src/gui.rs @@ -1,7 +1,6 @@ //! Control panel: menus for profile, routing mode, maps, and live crops. use crate::hotkey::Hotkey; -use crate::macros::print_macros; use crate::profile::{ Character, default_config_path, Group, LayoutMode, LayoutPreset, Map, Mode, NormRect, Profile, Repeater, Step, VideoFx, @@ -64,6 +63,7 @@ pub fn run() -> Result<()> { // 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, }; eframe::run_native("enBoxer", native, Box::new(|_cc| Ok(Box::new(app)))) .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 { path: PathBuf, profile: Profile, @@ -118,6 +126,8 @@ struct App { /// Item 1: Free-mode buttons push IPC verbs here; the main /// loop drains the queue and writes them to the socket. pending_ipc: Vec, + /// Item 2: which tree node is currently selected. + selected_node: Option, } #[derive(Default)] @@ -563,6 +573,7 @@ impl eframe::App for App { if ui.button("Add crop").clicked() { self.profile.video_fx.push(VideoFx { name: format!("crop{}", self.profile.video_fx.len() + 1), + category: "Video FX".to_string(), enabled: true, source_slot: 2, source: NormRect { @@ -635,7 +646,7 @@ impl eframe::App for App { Page::Layout => self.page_layout(ui), Page::Maps => self.page_maps(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), }); } @@ -786,6 +797,7 @@ impl App { if ui.button("Add map").clicked() { self.profile.maps.push(Map { name: format!("map{}", self.profile.maps.len() + 1), + category: "Mapped Keys".to_string(), hotkey: Hotkey("1".into()), hold: false, steps: vec![Step { @@ -865,6 +877,7 @@ impl App { if ui.button("Add crop").clicked() { self.profile.video_fx.push(VideoFx { name: format!("crop{}", self.profile.video_fx.len() + 1), + category: "Video FX".to_string(), enabled: true, source_slot: 2.min(self.profile.slots), source: NormRect { @@ -1304,15 +1317,206 @@ impl App { } } - fn page_macros(&mut self, ui: &mut egui::Ui) { - ui.heading("In-game macros"); - ui.label("Bind these on every account. enBoxer only sends keystrokes."); - let text = print_macros(&self.profile); - egui::ScrollArea::vertical().show(ui, |ui| { - ui.monospace(text); + fn page_keybinds(&mut self, ui: &mut egui::Ui) { + ui.heading("Key bindings + video FX"); + ui.label("Tree editor: click an item to edit it. Add as many or as few as you want per category."); + ui.horizontal(|ui| { + // LEFT: tree + 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) { 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/.yaml."); diff --git a/src/profile.rs b/src/profile.rs index 66d8ed9..f189707 100644 --- a/src/profile.rs +++ b/src/profile.rs @@ -179,6 +179,11 @@ pub struct Group { #[derive(Debug, Clone, Default, Serialize, Deserialize)] 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 hotkey: Hotkey, #[serde(default)] @@ -207,6 +212,9 @@ fn default_target() -> String { #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct VideoFx { + /// Item 2: tree category. Always "Video FX" for now. + #[serde(default = "default_vfx_category")] + pub category: String, pub name: String, #[serde(default = "default_true")] pub enabled: bool, @@ -324,6 +332,16 @@ fn default_one() -> f64 { 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 { /// Values > 1 are pixels inside the window; otherwise fractions 0..=1 of the window.