diff --git a/dsa/emulator/emulator_ui/src/ui/controller.rs b/dsa/emulator/emulator_ui/src/ui/controller.rs index a84223d..64f451d 100644 --- a/dsa/emulator/emulator_ui/src/ui/controller.rs +++ b/dsa/emulator/emulator_ui/src/ui/controller.rs @@ -51,9 +51,9 @@ impl Controller { } } - status.clone().ui(ui, padding); + if PaddedWidget::indicator(status).ui(ui).clicked() {} - if PaddedWidget::button("⟳ Reset All").color(Color32::YELLOW).ui(ui).clicked() { + if PaddedWidget::button("⟳ Reset All").color(Color32::ORANGE).ui(ui).clicked() { self.emulator.when_paused(|emu| emu.reset()); } diff --git a/dsa/emulator/emulator_ui/src/ui/panels/editor/mod.rs b/dsa/emulator/emulator_ui/src/ui/panels/editor/mod.rs index d430b97..4617744 100644 --- a/dsa/emulator/emulator_ui/src/ui/panels/editor/mod.rs +++ b/dsa/emulator/emulator_ui/src/ui/panels/editor/mod.rs @@ -81,6 +81,7 @@ impl TilePane for Editor { action = Some(Action::SpawnProcess(Box::new(move |pm| { pm.spawn( + "Assembler", Assembler::new(filename), move |result| { return if let Ok(bin) = result { diff --git a/dsa/emulator/emulator_ui/src/ui/panels/mod.rs b/dsa/emulator/emulator_ui/src/ui/panels/mod.rs index 6d824d2..95248e1 100644 --- a/dsa/emulator/emulator_ui/src/ui/panels/mod.rs +++ b/dsa/emulator/emulator_ui/src/ui/panels/mod.rs @@ -9,9 +9,12 @@ pub mod registers; pub mod serial; use std::any::Any; +use eframe::epaint::Color32; +use egui::{Id, Response, Sense, TextStyle, Ui, WidgetInfo, WidgetType}; use egui_tiles::{Behavior, Container, SimplificationOptions, TabState, Tile, TileId, Tiles, Tree, UiResponse}; use crate::ui::Action; use crate::ui::Action::RequestCloseTab; +use crate::ui::ui_helpers::{close_button_at, PaddedWidget, 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 @@ -64,6 +67,26 @@ impl Behavior for TileBehavior { UiResponse::None } + fn tab_ui( + &mut self, + tiles: &mut Tiles, + ui: &mut Ui, + id: Id, + tile_id: TileId, + state: &TabState, + ) -> Response { + let title = self.tab_title_for_tile(tiles, tile_id); + let draggable = self.is_tile_draggable(tiles, tile_id); + + let tab = painted_tab(ui, id, title, state.active, state.closable, draggable); + + if tab.close_clicked && self.on_tab_close(tiles, tile_id) { + tiles.remove(tile_id); + } + + self.on_tab_button(tiles, tile_id, tab.response) + } + fn tab_title_for_pane(&mut self, pane: &Pane) -> egui::WidgetText { pane.title() } @@ -86,7 +109,6 @@ impl Behavior for TileBehavior { true } - fn tab_bar_height(&self, _style: &egui::Style) -> f32 { 36.0 // taller tab row = more vertical padding around the label } diff --git a/dsa/emulator/emulator_ui/src/ui/process_manager.rs b/dsa/emulator/emulator_ui/src/ui/process_manager.rs index 1e9f194..fd9cf44 100644 --- a/dsa/emulator/emulator_ui/src/ui/process_manager.rs +++ b/dsa/emulator/emulator_ui/src/ui/process_manager.rs @@ -4,6 +4,8 @@ use egui::Ui; use std::collections::VecDeque; use std::sync::mpsc; use std::thread::JoinHandle; +use crate::ui::ui_helpers::{close_button, painted_tab}; +use crate::ui::ui_helpers::text::strip_ansi; pub type ProcessSpawner = Box; @@ -62,15 +64,15 @@ impl ProcessManager { self.terminated_actions.pop_front() } - pub fn spawn(&mut self, process: P, on_complete: impl FnOnce(Result) -> Action + Send + 'static) { - let handle = ProcessHandle::spawn(self.new_id(), "process", process, on_complete); + pub fn spawn(&mut self, name: impl Into, process: P, on_complete: impl FnOnce(Result) -> Action + Send + 'static) { + let handle = ProcessHandle::spawn(self.new_id(), name, process, on_complete); self.handles.push(handle); } fn drain_logs(&mut self) { for handle in &mut self.handles { while let Ok(line) = handle.rx.try_recv() { - handle.logs.push(line); + handle.logs.push(strip_ansi(&line)); } } } @@ -105,41 +107,33 @@ impl ProcessManager { self.active_tab = self.handles.first().map(|h| h.id); } - // --- tab bar --- + + let mut to_close = None; + ui.horizontal(|ui| { for handle in &self.handles { let selected = self.active_tab == Some(handle.id); - let running = handle.join_handle.is_some(); + let tab_id = ui.auto_id_with(("process_tab", handle.id)); + let tab = painted_tab(ui, tab_id, handle.name.clone(), selected, true, false); - let label = if running { - format!("● {}", handle.name) - } else { - format!("○ {}", handle.name) - }; - - if ui.selectable_label(selected, label).clicked() { + if tab.response.clicked() { self.active_tab = Some(handle.id); } + + if tab.close_clicked { + to_close = Some(handle.id); + } } }); - ui.separator(); + if let Some(id) = to_close { + self.handles.retain(|h| h.id != id); + if self.active_tab == Some(id) { + self.active_tab = self.handles.first().map(|h| h.id); + } + } - // --- close button for the active tab --- if let Some(active_id) = self.active_tab { - ui.horizontal(|ui| { - if let Some(handle) = self.handles.iter().find(|h| h.id == active_id) { - ui.label(egui::RichText::new(&handle.name).strong()); - } - - if ui.small_button("✕ Close").clicked() { - self.handles.retain(|h| h.id != active_id); - self.active_tab = self.handles.first().map(|h| h.id); - } - }); - - ui.separator(); - // --- log view for the active tab --- if let Some(handle) = self.handles.iter().find(|h| h.id == active_id) { egui::ScrollArea::vertical() diff --git a/dsa/emulator/emulator_ui/src/ui/ui_helpers/mod.rs b/dsa/emulator/emulator_ui/src/ui/ui_helpers/mod.rs index fb53d15..1297a7f 100644 --- a/dsa/emulator/emulator_ui/src/ui/ui_helpers/mod.rs +++ b/dsa/emulator/emulator_ui/src/ui/ui_helpers/mod.rs @@ -1,3 +1,5 @@ +pub mod text; + use eframe::emath::Vec2; use egui::{Color32, Frame, Stroke}; use egui::widget_style::{Classes, WidgetState}; @@ -17,9 +19,9 @@ enum WidgetType { } impl PaddedWidget { - pub fn button(text: impl AsRef) -> Self { + pub fn button(text: impl Into) -> Self { Self { - text: text.as_ref().to_string(), + text: text.into(), color: None, padding: None, widget_type: WidgetType::Button @@ -35,9 +37,9 @@ impl PaddedWidget { } } - pub fn label(text: String) -> Self { + pub fn label(text: impl Into) -> Self { Self { - text, + text: text.into(), color: None, padding: None, widget_type: WidgetType::Label @@ -57,7 +59,12 @@ impl PaddedWidget { 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 = galley.size() + self.padding.unwrap_or(Vec2::new(14.0, 8.0)) * 2.0; + + 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()); @@ -77,116 +84,38 @@ impl PaddedWidget { egui::StrokeKind::Inside, ); - let text_pos = rect.center() - galley.size() / 2.0; - ui.painter().galley(text_pos, galley, self.color.unwrap_or(visuals.fg_stroke.color)); + 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 fn frame_button(ui: &mut egui::Ui, text: impl Into, color: egui::Color32, padding: Vec2) -> egui::Response { -// let text: egui::WidgetText = text.into(); -// let galley = text.into_galley(ui, None, f32::INFINITY, egui::TextStyle::Button); -// let desired_size = galley.size() + padding * 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 = ui.style().interact(&response); -// -// ui.painter().rect( -// rect, -// visuals.corner_radius, -// visuals.bg_fill, -// visuals.bg_stroke, -// egui::StrokeKind::Inside, -// ); -// -// let text_pos = rect.center() - galley.size() / 2.0; -// ui.painter().galley(text_pos, galley, color); -// } -// -// response -// } -// -// pub fn info_field(ui: &mut egui::Ui, text: impl Into, padding: egui::Vec2) -> egui::Response { -// let text: egui::WidgetText = text.into(); -// let galley = text.into_galley(ui, None, f32::INFINITY, egui::TextStyle::Button); -// let desired_size = galley.size() + padding * 2.0; -// -// let (rect, response) = ui.allocate_exact_size(desired_size, egui::Sense::click()); -// -// if ui.is_rect_visible(rect) { -// // Always use `inactive` — no hover highlight, no active/pressed feedback either. -// // Response is still fully clickable; this only affects what gets drawn. -// let visuals = &ui.visuals().widgets.inactive; -// -// ui.painter().rect( -// rect, -// visuals.corner_radius, -// visuals.bg_fill, -// visuals.bg_stroke, -// egui::StrokeKind::Inside, -// ); -// -// let text_pos = rect.center() - galley.size() / 2.0; -// ui.painter().galley(text_pos, galley, visuals.fg_stroke.color); -// } -// -// response -// } - - -pub fn bordered_frame( - ui: &mut egui::Ui, - padding: egui::Vec2, - add_contents: impl FnOnce(&mut egui::Ui) -> R, -) -> egui::InnerResponse { - let visuals = ui.visuals().widgets.inactive; // same visuals a normal button uses - - egui::Frame::new() - .inner_margin(egui::Margin::symmetric(padding.x as i8, padding.y as i8)) - .stroke(visuals.bg_stroke) - .corner_radius(visuals.corner_radius) - .fill(visuals.bg_fill) - .show(ui, add_contents) -} pub trait StatusIndicator { - fn ui(&mut self, ui: &mut egui::Ui, padding: egui::Vec2) -> egui::Response { - bordered_frame(ui, padding, |ui| { - ui.horizontal(|ui| { - let dot_size = 8.0; - let (rect, _) = ui.allocate_exact_size( - egui::vec2(dot_size, dot_size), - egui::Sense::hover(), - ); - ui.painter().circle_filled(rect.center(), dot_size / 2.0, self.color()); - ui.label( self.label() ); - }) - .response - }) - .response - } - - fn color(&self) -> egui::Color32; fn label(&self) -> String; } - - impl StatusIndicator for RunningState { fn color(&self) -> egui::Color32 { match self { - RunningState::Running => egui::Color32::from_rgb(0x4c, 0xaf, 0x50), - RunningState::Paused => egui::Color32::from_rgb(0xff, 0xc1, 0x07), - RunningState::Halted => egui::Color32::from_rgb(0xff, 0xc1, 0x07), - RunningState::Breakpoint(_) => egui::Color32::from_rgb(0xe5, 0x39, 0x35), - RunningState::Shutdown => egui::Color32::from_rgb(0xff, 0xc1, 0x07), + RunningState::Running => egui::Color32::GREEN, + RunningState::Paused => egui::Color32::YELLOW, + RunningState::Halted => egui::Color32::ORANGE, + RunningState::Breakpoint(_) => egui::Color32::RED, + RunningState::Shutdown => egui::Color32::BLUE, } } @@ -199,4 +128,121 @@ impl StatusIndicator for RunningState { RunningState::Shutdown => "Shutdown".into(), } } +} + +/// 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/dsa/emulator/emulator_ui/src/ui/ui_helpers/text.rs b/dsa/emulator/emulator_ui/src/ui/ui_helpers/text.rs new file mode 100644 index 0000000..d80c76f --- /dev/null +++ b/dsa/emulator/emulator_ui/src/ui/ui_helpers/text.rs @@ -0,0 +1,22 @@ +pub fn strip_ansi(input: &str) -> String { + let mut output = String::with_capacity(input.len()); + let mut chars = input.chars().peekable(); + + while let Some(c) = chars.next() { + if c == '\x1B' { + if chars.peek() == Some(&'[') { + chars.next(); + while let Some(&next) = chars.peek() { + chars.next(); + if next.is_ascii_alphabetic() { + break; + } + } + } + continue; + } + output.push(c); + } + + output +} \ No newline at end of file