diff --git a/emulator/dsa_gui/src/ui/controller.rs b/emulator/dsa_gui/src/ui/controller.rs index d6d3f25..ed7020e 100644 --- a/emulator/dsa_gui/src/ui/controller.rs +++ b/emulator/dsa_gui/src/ui/controller.rs @@ -1,8 +1,9 @@ -use eframe::epaint::Color32; use common::prelude::Register; use dsa_emulator::EmulatorHandle; use dsa_emulator::processor::RunningState; -use crate::ui::ui_helpers::PaddedWidget; +use eframe::epaint::Color32; +use zxq5_egui::buttons::PaddedWidget; +use crate::ui::ui_helpers::StateWrapper; pub struct Controller { emulator: EmulatorHandle, @@ -23,7 +24,6 @@ impl Controller { .inner_margin(10.0) .show(ui, |ui| { ui.horizontal(|ui| { - use RunningState::*; let status = self.emulator.status(); @@ -43,7 +43,7 @@ impl Controller { } } - PaddedWidget::indicator(status).ui(ui); + PaddedWidget::indicator(StateWrapper(status)).ui(ui); if PaddedWidget::button("⟳ Reset All").color(Color32::ORANGE).ui(ui).clicked() { self.emulator.when_paused(|emu| emu.reset()); diff --git a/emulator/dsa_gui/src/ui/dialogs/confirm_dialog.rs b/emulator/dsa_gui/src/ui/dialogs/confirm_dialog.rs index 695e35d..705104c 100644 --- a/emulator/dsa_gui/src/ui/dialogs/confirm_dialog.rs +++ b/emulator/dsa_gui/src/ui/dialogs/confirm_dialog.rs @@ -1,5 +1,5 @@ -use egui::{Modal, Id, Layout, Align, RichText}; -use crate::ui::ui_helpers::PaddedWidget; +use egui::{Align, Id, Layout, Modal, RichText}; +use zxq5_egui::buttons::PaddedWidget; pub struct ConfirmDialog { open: bool, diff --git a/emulator/dsa_gui/src/ui/mod.rs b/emulator/dsa_gui/src/ui/mod.rs index 9bc4587..b31f5ff 100644 --- a/emulator/dsa_gui/src/ui/mod.rs +++ b/emulator/dsa_gui/src/ui/mod.rs @@ -8,27 +8,29 @@ use crate::ui::tiles::registers::Registers; use crate::ui::tiles::{CentralTiles, TilePane}; use dialogs::confirm_dialog::ConfirmDialog; use dialogs::file_dialog::DsaFileDialog; -use dsa_formats::binary_dse::DseExecutable; -use egui::{CentralPanel, Panel, Ui}; -use egui_tiles::TileId; use dsa_emulator::EmulatorController; use dsa_emulator::devices::display::Display; use dsa_emulator::devices::serial::Serial; +use dsa_formats::binary_dse::DseExecutable; +use egui::{CentralPanel, Panel, Ui}; +use egui_tiles::TileId; +use process_manager::ProcessManager; +use std::fs::File; +use std::io::BufReader; +use std::path::Path; +use std::time::Duration; use tiles::PanelFactory; use tiles::documentation::Documentation; use tiles::editor::Editor; use tiles::io_devices::framebuffer::FrameBufferUi; -use process_manager::ProcessManager; -use std::fs::File; -use std::io::BufReader; -use std::time::Duration; +use zxq5_egui::file_tree::{FileTree, FileTreeEvent}; +pub mod appearance; +mod controller; mod dialogs; +pub mod process_manager; mod tiles; mod ui_helpers; -mod controller; -pub mod process_manager; -pub mod appearance; pub struct DamnSimpleUI { emulator: EmulatorController, @@ -42,10 +44,10 @@ pub struct DamnSimpleUI { tools: Vec, show_process_manager: bool, - process_manager: ProcessManager + process_manager: ProcessManager, + file_tree: FileTree, } - impl eframe::App for DamnSimpleUI { fn ui(&mut self, ui: &mut Ui, frame: &mut eframe::Frame) { ui.request_repaint_after(Duration::from_millis(16)); @@ -72,13 +74,26 @@ impl eframe::App for DamnSimpleUI { self.controller.ui(ui); }); - Panel::left("left_panel").resizable(false).show(ui, |ui| { - // TODO: File Explorer! + Panel::left("left_panel").resizable(true).show(ui, |ui| { + let inner_res = self.file_tree.ui(ui, &mut |ui: &mut Ui, path: &Path| { + ui.button("Load Binary") + .clicked() + .then_some(Action::LoadBinary(DataSource::File(path.to_path_buf()))) + }); + + if let Some(event) = inner_res.inner { + match event { + FileTreeEvent::Clicked(path) => self.perform_action(Action::OpenFile(path)), + FileTreeEvent::ContextAction(action) => self.perform_action(action), + } + } }); - Panel::bottom("bottom_panel").resizable(true).show_collapsible(ui, &mut self.show_process_manager,|ui| { - self.process_manager.ui(ui); - }); + Panel::bottom("bottom_panel") + .resizable(true) + .show(ui, |ui| { + self.process_manager.ui(ui); + }); CentralPanel::default().show(ui, |ui| { if let Some(action) = self.central_tiles.ui(ui) { @@ -88,11 +103,7 @@ impl eframe::App for DamnSimpleUI { } } - - - impl DamnSimpleUI { - fn open_or_focus( central_tiles: &mut CentralTiles, slot: &mut Option, @@ -104,10 +115,7 @@ impl DamnSimpleUI { } } - pub fn new( - cc: &eframe::CreationContext, - mut emulator: EmulatorController, - ) -> Self { + pub fn new(cc: &eframe::CreationContext, mut emulator: EmulatorController) -> Self { appearance::configure_appearance(&cc.egui_ctx); let handle = emulator.get_handle().clone(); Self { @@ -116,7 +124,7 @@ impl DamnSimpleUI { confirm_dialog: ConfirmDialog::new(), file_dialog: DsaFileDialog::new(), error_dialog: ErrorDialog::default(), - + tools: vec![ PanelFactory::register("Memory Inspector", { let handle = handle.clone(); @@ -131,31 +139,32 @@ impl DamnSimpleUI { let handle = handle.clone(); move || FrameBufferUi::new(320, 240, 0x30000, handle.clone()) }), - PanelFactory::register("Serial", { + PanelFactory::register("Serial", { let handle = handle.clone(); move || Serial::new(0x40000, handle.clone()) }), - PanelFactory::register("Registers", { + PanelFactory::register("Registers", { let handle = handle.clone(); move || Registers::new(handle.clone()) - }) + }), ], emulator, show_process_manager: true, - process_manager: ProcessManager::new() + process_manager: ProcessManager::new(), + file_tree: FileTree::build(&std::env::current_dir().unwrap()), } } pub fn perform_action(&mut self, action: Action) { match action { - Action::None => {}, + Action::None => {} Action::SpawnProcess(spawner) => spawner(&mut self.process_manager), Action::RequestCloseTab(tile_id) => { self.confirm_dialog.open( "Close Tab", "Closing this tab may result in losing unsaved work", - Action::CloseTab(tile_id) + Action::CloseTab(tile_id), ); } @@ -166,15 +175,24 @@ impl DamnSimpleUI { Action::Quit => std::process::exit(0), // Opens a file (usually constructed by the file dialogue) - Action::OpenFile(path) => { - match path.extension().map(|s| s.to_str()).flatten() { - Some("dsa" | "dsc") => { self.central_tiles.add_tab(Editor::new_open(&path)); }, - Some("dsb") => { self.central_tiles.add_tab(BinaryViewer::new_open(&path)); }, - Some("md") => { self.central_tiles.add_tab(Documentation::open(&path)); }, - Some(_) => todo!(), - None => {}, + Action::OpenFile(path) => match path.extension().map(|s| s.to_str()).flatten() { + Some("dsa" | "dsc") => { + self.central_tiles.add_tab(Editor::new_open(&path)); } - } + Some("dsb") => { + self.central_tiles.add_tab(BinaryViewer::new_open(&path)); + } + Some("md") => { + self.central_tiles.add_tab(Documentation::open(&path)); + } + Some(f) => { + self.perform_action(Action::ShowError(format!( + "Unsupported file extension: {}", + f + ))); + } + None => {} + }, Action::ShowError(err) => { self.error_dialog.open(err); @@ -187,11 +205,12 @@ impl DamnSimpleUI { } Action::LoadBinary(DataSource::File(path)) => { - if let Some(ext) = path.extension().map(|s| s.to_str()).flatten() && ext == "dsb" { - + if let Some(ext) = path.extension().map(|s| s.to_str()).flatten() + && ext == "dsb" + { let Ok(file) = File::open(path) else { self.perform_action(Action::ShowError("Failed to open file".into())); - return + return; }; let Ok(binary) = DseExecutable::read(&mut BufReader::new(file)) else { @@ -215,50 +234,67 @@ impl DamnSimpleUI { } Action::RequestSaveAs(tile) => { - self.file_dialog.open_save(move |path| Action::SaveFile(path, tile)); + self.file_dialog + .open_save(move |path| Action::SaveFile(path, tile)); } }; } fn toolbar(&mut self, ui: &mut Ui) { egui::MenuBar::new().ui(ui, |ui| { - - let save_enabled = self.central_tiles.active_tile() + let save_enabled = self + .central_tiles + .active_tile() .and_then(|id| self.central_tiles.pane_mut::(id)) .is_some(); ui.set_height(30.0); ui.menu_button("File", |ui| { - if ui.button("Open").clicked() { self.file_dialog.open_pick(Action::OpenFile); } if ui.button("Load Binary").clicked() { - self.file_dialog.open_pick(|path| Action::LoadBinary(DataSource::File(path))); + self.file_dialog + .open_pick(|path| Action::LoadBinary(DataSource::File(path))); } - if ui.add_enabled(save_enabled, egui::Button::new("Save")).clicked() { + if ui + .add_enabled(save_enabled, egui::Button::new("Save")) + .clicked() + { if let Some(active) = self.central_tiles.active_tile() { if let Some(editor) = self.central_tiles.pane_mut::(active) { match editor.filename().cloned() { - Some(path) => std::fs::write(path, editor.get_buffer().as_bytes()).unwrap(), - None => self.file_dialog.open_save(move |path| Action::SaveFile(path, active)), + Some(path) => { + std::fs::write(path, editor.get_buffer().as_bytes()).unwrap() + } + None => self + .file_dialog + .open_save(move |path| Action::SaveFile(path, active)), } } } } - if ui.add_enabled(save_enabled, egui::Button::new("Save As")).clicked() { + if ui + .add_enabled(save_enabled, egui::Button::new("Save As")) + .clicked() + { if let Some(active) = self.central_tiles.active_tile() { if let Some(editor) = self.central_tiles.pane_mut::(active) { - self.file_dialog.open_save(move |path| Action::SaveFile(path, active)) + self.file_dialog + .open_save(move |path| Action::SaveFile(path, active)) } } } if ui.button("Exit").clicked() { - self.confirm_dialog.open("Exit","Exit Application\n(you will lose unsaved work)", Action::Quit) + self.confirm_dialog.open( + "Exit", + "Exit Application\n(you will lose unsaved work)", + Action::Quit, + ) } }); @@ -278,4 +314,3 @@ impl DamnSimpleUI { }); } } - diff --git a/emulator/dsa_gui/src/ui/process_manager.rs b/emulator/dsa_gui/src/ui/process_manager.rs index 5129a8c..02f96cb 100644 --- a/emulator/dsa_gui/src/ui/process_manager.rs +++ b/emulator/dsa_gui/src/ui/process_manager.rs @@ -1,10 +1,10 @@ use crate::state::Action; -use crate::ui::ui_helpers::painted_tab; use crate::ui::ui_helpers::text::strip_ansi; use dsa_tasks::{ProcessAgent, TraceableProcess}; use std::collections::VecDeque; use std::sync::mpsc; use std::thread::JoinHandle; +use zxq5_egui::tabs::painted_tab; pub type ProcessSpawner = Box; diff --git a/emulator/dsa_gui/src/ui/tiles/editor/mod.rs b/emulator/dsa_gui/src/ui/tiles/editor/mod.rs index c9bad2a..c17ad8c 100644 --- a/emulator/dsa_gui/src/ui/tiles/editor/mod.rs +++ b/emulator/dsa_gui/src/ui/tiles/editor/mod.rs @@ -2,7 +2,6 @@ use crate::state::Action; use crate::state::DataSource::Data; use crate::ui::tiles::TilePane; use crate::ui::tiles::editor::syntax::SyntaxFrom; -use crate::ui::ui_helpers::PaddedWidget; use assembler::prelude::Assembler; use dsa_formats::binary_dse::DseExecutable; use dsa_tasks::TraceableProcess; @@ -13,6 +12,7 @@ use std::any::Any; use std::fs::File; use std::path::{Path, PathBuf}; use theme::THEME; +use zxq5_egui::buttons::PaddedWidget; mod syntax; mod theme; diff --git a/emulator/dsa_gui/src/ui/tiles/mod.rs b/emulator/dsa_gui/src/ui/tiles/mod.rs index 6cee663..3d3ef6a 100644 --- a/emulator/dsa_gui/src/ui/tiles/mod.rs +++ b/emulator/dsa_gui/src/ui/tiles/mod.rs @@ -8,11 +8,11 @@ pub mod io_devices; use crate::state::Action; use crate::state::Action::RequestCloseTab; -use crate::ui::ui_helpers::painted_tab; use eframe::epaint::{Color32, CornerRadius}; use egui::{Id, Response, Stroke, Ui}; use egui_tiles::{Behavior, Container, SimplificationOptions, TabState, Tile, TileId, Tiles, Tree, UiResponse}; use std::any::Any; +use zxq5_egui::tabs::painted_tab; /// Anything that can live inside a tab in the central tiled area. /// `as_any_mut` lets callers reach back into a specific pane's concrete diff --git a/emulator/dsa_gui/src/ui/ui_helpers/mod.rs b/emulator/dsa_gui/src/ui/ui_helpers/mod.rs index 91091dc..812078a 100644 --- a/emulator/dsa_gui/src/ui/ui_helpers/mod.rs +++ b/emulator/dsa_gui/src/ui/ui_helpers/mod.rs @@ -1,115 +1,13 @@ pub mod text; -use eframe::emath::Vec2; -use egui::{Color32}; use dsa_emulator::processor::RunningState; +use zxq5_egui::buttons::StatusIndicator; -pub struct PaddedWidget { - text: String, - color: Option, - padding: Option, - widget_type: WidgetType -} +pub struct StateWrapper(pub RunningState); -enum WidgetType { - Button, - Indicator, - Label -} - -impl PaddedWidget { - pub fn button(text: impl Into) -> Self { - Self { - text: text.into(), - color: None, - padding: None, - widget_type: WidgetType::Button - } - } - - pub fn indicator(i: impl StatusIndicator) -> Self { - Self { - text: i.label().to_string(), - color: Some(i.color()), - padding: None, - widget_type: WidgetType::Indicator - } - } - - pub fn label(text: impl Into) -> Self { - Self { - text: text.into(), - color: None, - padding: None, - widget_type: WidgetType::Label - } - } - - pub fn color(&mut self, color: Color32) -> &mut Self { - self.color = Some(color); - self - } - - pub fn padding(&mut self, padding: Vec2) -> &mut Self { - self.padding = Some(padding); - self - } - - pub fn ui(&mut self, ui: &mut egui::Ui) -> egui::Response { - let text: egui::WidgetText = self.text.clone().into(); - let galley = text.into_galley(ui, None, f32::INFINITY, egui::TextStyle::Button); - - let desired_size = if let WidgetType::Indicator = self.widget_type { - galley.size() + egui::vec2(12.0, 0.0) + self.padding.unwrap_or(Vec2::new(14.0, 8.0)) * 2.0 - } else { - galley.size() + self.padding.unwrap_or(Vec2::new(14.0, 8.0)) * 2.0 - }; - - let (rect, response) = ui.allocate_exact_size(desired_size, egui::Sense::click()); - - if ui.is_rect_visible(rect) { - // picks noninteractive/inactive/hovered/active automatically based on `response` - - let visuals = match self.widget_type { - WidgetType::Button => ui.style().interact(&response), - _ => &ui.visuals().widgets.inactive, - }; - - ui.painter().rect( - rect, - visuals.corner_radius, - visuals.bg_fill, - visuals.bg_stroke, - egui::StrokeKind::Inside, - ); - - if let WidgetType::Indicator = self.widget_type { - let size = egui::vec2(galley.size().clone().x + 6.0, 0.0); - let text_pos = rect.center() - galley.size() / 2.0 + egui::vec2(8.0, 0.0); - ui.painter().galley(text_pos, galley, self.color.unwrap_or(visuals.fg_stroke.color)); - ui.painter().circle_filled(rect.center() - size / 2.0, 8.0 / 2.0, self.color.unwrap()); - } - - else { - let text_pos = rect.center() - galley.size() / 2.0; - ui.painter().galley(text_pos, galley, self.color.unwrap_or(visuals.fg_stroke.color)); - } - } - - response - } -} - - -pub trait StatusIndicator { - fn color(&self) -> egui::Color32; - - fn label(&self) -> String; -} - -impl StatusIndicator for RunningState { +impl StatusIndicator for StateWrapper { fn color(&self) -> egui::Color32 { - match self { + match self.0 { RunningState::Running => egui::Color32::GREEN, RunningState::Paused => egui::Color32::YELLOW, RunningState::Halted => egui::Color32::ORANGE, @@ -119,7 +17,7 @@ impl StatusIndicator for RunningState { } fn label(&self) -> String { - match self { + match self.0 { RunningState::Running => "Running".into(), RunningState::Paused => "Paused".into(), RunningState::Halted => "Halted".into(), @@ -129,119 +27,3 @@ impl StatusIndicator for RunningState { } } -/// Shared paint logic — draws the crossed lines, using whatever -/// interact-state visuals (hover/press color, expansion) egui's style -/// would normally pick for this response. -fn paint_close_x(ui: &egui::Ui, rect: egui::Rect, response: &egui::Response) { - if !ui.is_rect_visible(rect) { - return; - } - let visuals = ui.style().interact(response); - let inner = rect.shrink(4.0).expand(visuals.expansion); - let stroke = visuals.fg_stroke; - ui.painter().line_segment([inner.left_top(), inner.right_bottom()], stroke); - ui.painter().line_segment([inner.right_top(), inner.left_bottom()], stroke); -} - -/// Standalone close button — allocates its own space in the current -/// layout, like a normal widget. Use this in a `ui.horizontal(...)` row, -/// e.g. next to a label in a flat list (process manager tabs, a file -/// list with per-row close, etc). -pub fn close_button(ui: &mut egui::Ui, size: f32) -> egui::Response { - let (rect, response) = ui.allocate_exact_size(egui::vec2(size, size), egui::Sense::click()); - let response = response.on_hover_cursor(egui::CursorIcon::Default); - - response.widget_info(|| { - egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), "Close") - }); - - paint_close_x(ui, rect, &response); - response -} - -/// Close button at an explicit rect you've already computed — for -/// cases like tab_ui, where the button needs to sit inside a larger -/// region you're laying out manually rather than in normal flow. -pub fn close_button_at(ui: &mut egui::Ui, rect: egui::Rect, id: egui::Id) -> egui::Response { - let response = ui - .interact(rect, id, egui::Sense::click_and_drag()) - .on_hover_cursor(egui::CursorIcon::Default); - - response.widget_info(|| { - egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), "Close") - }); - - paint_close_x(ui, rect, &response); - response -} - -pub struct TabResponse { - pub response: egui::Response, - pub close_clicked: bool, -} - -pub fn painted_tab( - ui: &mut egui::Ui, - id: egui::Id, - title: impl Into, - selected: bool, - closable: bool, - draggable: bool, -) -> TabResponse { - let padding = egui::vec2(14.0, 8.0); - let close_size = 14.0; - let close_gap = 6.0; - - let font_id = egui::TextStyle::Button.resolve(ui.style()); - let galley = title.into().into_galley(ui, Some(egui::TextWrapMode::Extend), f32::INFINITY, font_id); - - let extra_width = if closable { close_size + close_gap } else { 0.0 }; - let desired = egui::vec2( - galley.size().x + padding.x * 2.0 + extra_width, - ui.available_height().max(galley.size().y + padding.y * 2.0), - ); - - let (_, rect) = ui.allocate_space(desired); - - let sense = if draggable { egui::Sense::click_and_drag() } else { egui::Sense::click() }; - let response = ui.interact(rect, id, sense); - let response = if draggable { - response.on_hover_cursor(egui::CursorIcon::Grab) - } else { - response - }; - - response.widget_info(|| { - egui::WidgetInfo::selected(egui::WidgetType::Button, ui.is_enabled(), selected, galley.text()) - }); - - let mut close_clicked = false; - - if ui.is_rect_visible(rect) { - let visuals = ui.style().interact(&response); - - ui.painter().rect(rect, 4.0, visuals.bg_fill, visuals.bg_stroke, egui::StrokeKind::Inside); - - let text_color = if selected { - egui::Color32::WHITE - } else { - egui::Color32::DARK_GRAY - }; - - let text_pos = egui::pos2(rect.min.x + padding.x, rect.center().y - galley.size().y / 2.0); - ui.painter().galley(text_pos, galley, text_color); - - if closable { - let close_rect = egui::Rect::from_min_size( - egui::pos2(rect.max.x - padding.x - close_size, rect.center().y - close_size / 2.0), - egui::vec2(close_size, close_size), - ); - let close_id = id.with("close"); - let close_response = close_button_at(ui, close_rect, close_id); - - close_clicked = close_response.clicked() || response.clicked_by(egui::PointerButton::Middle); - } - } - - TabResponse { response, close_clicked } -} \ No newline at end of file diff --git a/libraries/zxq5_egui/Cargo.toml b/libraries/zxq5_egui/Cargo.toml index d1dddd6..6f3c53e 100644 --- a/libraries/zxq5_egui/Cargo.toml +++ b/libraries/zxq5_egui/Cargo.toml @@ -5,3 +5,4 @@ version.workspace = true authors.workspace = true [dependencies] +egui = "0.35.0" diff --git a/libraries/zxq5_egui/src/buttons.rs b/libraries/zxq5_egui/src/buttons.rs index e69de29..8faa161 100644 --- a/libraries/zxq5_egui/src/buttons.rs +++ b/libraries/zxq5_egui/src/buttons.rs @@ -0,0 +1,103 @@ +use egui::{Color32, Vec2}; + +pub struct PaddedWidget { + text: String, + color: Option, + padding: Option, + widget_type: WidgetType +} + +enum WidgetType { + Button, + Indicator, + Label +} + +impl PaddedWidget { + pub fn button(text: impl Into) -> Self { + Self { + text: text.into(), + color: None, + padding: None, + widget_type: WidgetType::Button + } + } + + pub fn indicator(i: impl StatusIndicator) -> Self { + Self { + text: i.label().to_string(), + color: Some(i.color()), + padding: None, + widget_type: WidgetType::Indicator + } + } + + pub fn label(text: impl Into) -> Self { + Self { + text: text.into(), + color: None, + padding: None, + widget_type: WidgetType::Label + } + } + + pub fn color(&mut self, color: Color32) -> &mut Self { + self.color = Some(color); + self + } + + pub fn padding(&mut self, padding: Vec2) -> &mut Self { + self.padding = Some(padding); + self + } + + pub fn ui(&mut self, ui: &mut egui::Ui) -> egui::Response { + let text: egui::WidgetText = self.text.clone().into(); + let galley = text.into_galley(ui, None, f32::INFINITY, egui::TextStyle::Button); + + let desired_size = if let WidgetType::Indicator = self.widget_type { + galley.size() + egui::vec2(12.0, 0.0) + self.padding.unwrap_or(Vec2::new(14.0, 8.0)) * 2.0 + } else { + galley.size() + self.padding.unwrap_or(Vec2::new(14.0, 8.0)) * 2.0 + }; + + let (rect, response) = ui.allocate_exact_size(desired_size, egui::Sense::click()); + + if ui.is_rect_visible(rect) { + // picks noninteractive/inactive/hovered/active automatically based on `response` + + let visuals = match self.widget_type { + WidgetType::Button => ui.style().interact(&response), + _ => &ui.visuals().widgets.inactive, + }; + + ui.painter().rect( + rect, + visuals.corner_radius, + visuals.bg_fill, + visuals.bg_stroke, + egui::StrokeKind::Inside, + ); + + if let WidgetType::Indicator = self.widget_type { + let size = egui::vec2(galley.size().clone().x + 6.0, 0.0); + let text_pos = rect.center() - galley.size() / 2.0 + egui::vec2(8.0, 0.0); + ui.painter().galley(text_pos, galley, self.color.unwrap_or(visuals.fg_stroke.color)); + ui.painter().circle_filled(rect.center() - size / 2.0, 8.0 / 2.0, self.color.unwrap()); + } + + else { + let text_pos = rect.center() - galley.size() / 2.0; + ui.painter().galley(text_pos, galley, self.color.unwrap_or(visuals.fg_stroke.color)); + } + } + + response + } +} + +pub trait StatusIndicator { + fn color(&self) -> egui::Color32; + + fn label(&self) -> String; +} \ No newline at end of file diff --git a/libraries/zxq5_egui/src/file_tree.rs b/libraries/zxq5_egui/src/file_tree.rs new file mode 100644 index 0000000..446e6e0 --- /dev/null +++ b/libraries/zxq5_egui/src/file_tree.rs @@ -0,0 +1,121 @@ +use egui::{CollapsingHeader, InnerResponse}; +use std::cmp::Ordering; +use std::path::{Path, PathBuf}; + +pub enum FileTree { + Branch { + path: PathBuf, + children: Vec, + }, + Leaf { + filename: PathBuf, + }, +} + +impl Eq for FileTree {} + +impl PartialEq for FileTree { + fn eq(&self, other: &Self) -> bool { + matches!( + (self, other), + (Self::Branch { .. }, Self::Branch { .. }) | (Self::Leaf { .. }, Self::Leaf { .. }) + ) + } +} + +impl PartialOrd for FileTree { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for FileTree { + fn cmp(&self, other: &Self) -> Ordering { + match (self, other) { + (Self::Leaf { filename: a }, Self::Leaf { filename: b }) => a.cmp(b), + (Self::Branch { path: a, .. }, Self::Branch { path: b, .. }) => a.cmp(b), + (Self::Branch { .. }, _) => Ordering::Less, + (_, Self::Branch { .. }) => Ordering::Greater, + } + } +} + +pub enum FileTreeEvent { + Clicked(PathBuf), + ContextAction(A), +} + +impl FileTree { + pub fn ui( + &self, + ui: &mut egui::Ui, + ctx: &mut impl FnMut(&mut egui::Ui, &Path) -> Option, + ) -> InnerResponse>> { + let mut action = None; + let frame_response = egui::Frame::new().show(ui, |ui| { + egui::ScrollArea::both() + .auto_shrink([false; 2]) + .show(ui, |ui| { + self.ui_inner(ui, &mut action, ctx); + }) + }); + InnerResponse::new(action, frame_response.response) + } + + fn ui_inner( + &self, + ui: &mut egui::Ui, + action: &mut Option>, + ctx: &mut impl FnMut(&mut egui::Ui, &Path) -> Option, + ) { + match self { + Self::Leaf { filename } => { + let response = ui.button(filename.file_name().unwrap().to_string_lossy()); + if response.clicked() { + *action = Some(FileTreeEvent::Clicked(PathBuf::from(filename))); + } + response.context_menu(|ui| { + if let Some(a) = ctx(ui, filename) { + *action = Some(FileTreeEvent::ContextAction(a)); + } + }); + } + Self::Branch { path, children } => { + CollapsingHeader::new(path.file_name().unwrap().to_string_lossy()) + .show(ui, |ui| { + for child in children { + child.ui_inner(ui, action, ctx); + } + }) + .header_response + .context_menu(|ui| { + if let Some(a) = ctx(ui, path) { + *action = Some(FileTreeEvent::ContextAction(a)); + } + }); + } + } + } + + pub fn build(path: &Path) -> Self { + match path.is_dir() { + true => Self::Branch { + path: path.to_path_buf(), + children: { + let mut children: Vec = std::fs::read_dir(path) + .map(|ok| { + ok.filter_map(|file| file.ok()) + .map(|file| Self::build(&path.join(file.file_name()))) + .collect() + }) + .unwrap_or_default(); + children.sort_unstable(); + children + }, + }, + false => Self::Leaf { + filename: path.to_path_buf(), + }, + } + } +} diff --git a/libraries/zxq5_egui/src/lib.rs b/libraries/zxq5_egui/src/lib.rs index 96f45b5..c75dd1f 100644 --- a/libraries/zxq5_egui/src/lib.rs +++ b/libraries/zxq5_egui/src/lib.rs @@ -1,2 +1,3 @@ pub mod buttons; pub mod tabs; +pub mod file_tree; diff --git a/libraries/zxq5_egui/src/tabs.rs b/libraries/zxq5_egui/src/tabs.rs index e69de29..10aebf9 100644 --- a/libraries/zxq5_egui/src/tabs.rs +++ b/libraries/zxq5_egui/src/tabs.rs @@ -0,0 +1,116 @@ +/// Shared paint logic — draws the crossed lines, using whatever +/// interact-state visuals (hover/press color, expansion) egui's style +/// would normally pick for this response. +fn paint_close_x(ui: &egui::Ui, rect: egui::Rect, response: &egui::Response) { + if !ui.is_rect_visible(rect) { + return; + } + let visuals = ui.style().interact(response); + let inner = rect.shrink(4.0).expand(visuals.expansion); + let stroke = visuals.fg_stroke; + ui.painter().line_segment([inner.left_top(), inner.right_bottom()], stroke); + ui.painter().line_segment([inner.right_top(), inner.left_bottom()], stroke); +} + +/// Standalone close button — allocates its own space in the current +/// layout, like a normal widget. Use this in a `ui.horizontal(...)` row, +/// e.g. next to a label in a flat list (process manager tabs, a file +/// list with per-row close, etc). +pub fn close_button(ui: &mut egui::Ui, size: f32) -> egui::Response { + let (rect, response) = ui.allocate_exact_size(egui::vec2(size, size), egui::Sense::click()); + let response = response.on_hover_cursor(egui::CursorIcon::Default); + + response.widget_info(|| { + egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), "Close") + }); + + paint_close_x(ui, rect, &response); + response +} + +/// Close button at an explicit rect you've already computed — for +/// cases like tab_ui, where the button needs to sit inside a larger +/// region you're laying out manually rather than in normal flow. +pub fn close_button_at(ui: &mut egui::Ui, rect: egui::Rect, id: egui::Id) -> egui::Response { + let response = ui + .interact(rect, id, egui::Sense::click_and_drag()) + .on_hover_cursor(egui::CursorIcon::Default); + + response.widget_info(|| { + egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), "Close") + }); + + paint_close_x(ui, rect, &response); + response +} + +pub struct TabResponse { + pub response: egui::Response, + pub close_clicked: bool, +} + +pub fn painted_tab( + ui: &mut egui::Ui, + id: egui::Id, + title: impl Into, + selected: bool, + closable: bool, + draggable: bool, +) -> TabResponse { + let padding = egui::vec2(14.0, 8.0); + let close_size = 14.0; + let close_gap = 6.0; + + let font_id = egui::TextStyle::Button.resolve(ui.style()); + let galley = title.into().into_galley(ui, Some(egui::TextWrapMode::Extend), f32::INFINITY, font_id); + + let extra_width = if closable { close_size + close_gap } else { 0.0 }; + let desired = egui::vec2( + galley.size().x + padding.x * 2.0 + extra_width, + ui.available_height().max(galley.size().y + padding.y * 2.0), + ); + + let (_, rect) = ui.allocate_space(desired); + + let sense = if draggable { egui::Sense::click_and_drag() } else { egui::Sense::click() }; + let response = ui.interact(rect, id, sense); + let response = if draggable { + response.on_hover_cursor(egui::CursorIcon::Grab) + } else { + response + }; + + response.widget_info(|| { + egui::WidgetInfo::selected(egui::WidgetType::Button, ui.is_enabled(), selected, galley.text()) + }); + + let mut close_clicked = false; + + if ui.is_rect_visible(rect) { + let visuals = ui.style().interact(&response); + + ui.painter().rect(rect, 4.0, visuals.bg_fill, visuals.bg_stroke, egui::StrokeKind::Inside); + + let text_color = if selected { + egui::Color32::WHITE + } else { + egui::Color32::DARK_GRAY + }; + + let text_pos = egui::pos2(rect.min.x + padding.x, rect.center().y - galley.size().y / 2.0); + ui.painter().galley(text_pos, galley, text_color); + + if closable { + let close_rect = egui::Rect::from_min_size( + egui::pos2(rect.max.x - padding.x - close_size, rect.center().y - close_size / 2.0), + egui::vec2(close_size, close_size), + ); + let close_id = id.with("close"); + let close_response = close_button_at(ui, close_rect, close_id); + + close_clicked = close_response.clicked() || response.clicked_by(egui::PointerButton::Middle); + } + } + + TabResponse { response, close_clicked } +} \ No newline at end of file