unified tab style in UI

This commit is contained in:
2026-07-30 00:48:24 +01:00
parent d616a116f5
commit fd3fed5143
6 changed files with 214 additions and 129 deletions
@@ -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()); self.emulator.when_paused(|emu| emu.reset());
} }
@@ -81,6 +81,7 @@ impl TilePane for Editor {
action = Some(Action::SpawnProcess(Box::new(move |pm| { action = Some(Action::SpawnProcess(Box::new(move |pm| {
pm.spawn( pm.spawn(
"Assembler",
Assembler::new(filename), Assembler::new(filename),
move |result| { move |result| {
return if let Ok(bin) = result { return if let Ok(bin) = result {
+23 -1
View File
@@ -9,9 +9,12 @@ pub mod registers;
pub mod serial; pub mod serial;
use std::any::Any; 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 egui_tiles::{Behavior, Container, SimplificationOptions, TabState, Tile, TileId, Tiles, Tree, UiResponse};
use crate::ui::Action; use crate::ui::Action;
use crate::ui::Action::RequestCloseTab; 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. /// 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 /// `as_any_mut` lets callers reach back into a specific pane's concrete
@@ -64,6 +67,26 @@ impl Behavior<Pane> for TileBehavior {
UiResponse::None UiResponse::None
} }
fn tab_ui(
&mut self,
tiles: &mut Tiles<Pane>,
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 { fn tab_title_for_pane(&mut self, pane: &Pane) -> egui::WidgetText {
pane.title() pane.title()
} }
@@ -86,7 +109,6 @@ impl Behavior<Pane> for TileBehavior {
true true
} }
fn tab_bar_height(&self, _style: &egui::Style) -> f32 { fn tab_bar_height(&self, _style: &egui::Style) -> f32 {
36.0 // taller tab row = more vertical padding around the label 36.0 // taller tab row = more vertical padding around the label
} }
@@ -4,6 +4,8 @@ use egui::Ui;
use std::collections::VecDeque; use std::collections::VecDeque;
use std::sync::mpsc; use std::sync::mpsc;
use std::thread::JoinHandle; 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<dyn FnOnce(&mut ProcessManager) + Send>; pub type ProcessSpawner = Box<dyn FnOnce(&mut ProcessManager) + Send>;
@@ -62,15 +64,15 @@ impl ProcessManager {
self.terminated_actions.pop_front() self.terminated_actions.pop_front()
} }
pub fn spawn<P: TraceableProcess>(&mut self, process: P, on_complete: impl FnOnce(Result<P::Output, P::Error>) -> Action + Send + 'static) { pub fn spawn<P: TraceableProcess>(&mut self, name: impl Into<String>, process: P, on_complete: impl FnOnce(Result<P::Output, P::Error>) -> Action + Send + 'static) {
let handle = ProcessHandle::spawn(self.new_id(), "process", process, on_complete); let handle = ProcessHandle::spawn(self.new_id(), name, process, on_complete);
self.handles.push(handle); self.handles.push(handle);
} }
fn drain_logs(&mut self) { fn drain_logs(&mut self) {
for handle in &mut self.handles { for handle in &mut self.handles {
while let Ok(line) = handle.rx.try_recv() { 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); self.active_tab = self.handles.first().map(|h| h.id);
} }
// --- tab bar ---
let mut to_close = None;
ui.horizontal(|ui| { ui.horizontal(|ui| {
for handle in &self.handles { for handle in &self.handles {
let selected = self.active_tab == Some(handle.id); 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 { if tab.response.clicked() {
format!("● {}", handle.name)
} else {
format!("○ {}", handle.name)
};
if ui.selectable_label(selected, label).clicked() {
self.active_tab = Some(handle.id); 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 { 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 --- // --- log view for the active tab ---
if let Some(handle) = self.handles.iter().find(|h| h.id == active_id) { if let Some(handle) = self.handles.iter().find(|h| h.id == active_id) {
egui::ScrollArea::vertical() egui::ScrollArea::vertical()
+145 -99
View File
@@ -1,3 +1,5 @@
pub mod text;
use eframe::emath::Vec2; use eframe::emath::Vec2;
use egui::{Color32, Frame, Stroke}; use egui::{Color32, Frame, Stroke};
use egui::widget_style::{Classes, WidgetState}; use egui::widget_style::{Classes, WidgetState};
@@ -17,9 +19,9 @@ enum WidgetType {
} }
impl PaddedWidget { impl PaddedWidget {
pub fn button(text: impl AsRef<str>) -> Self { pub fn button(text: impl Into<String>) -> Self {
Self { Self {
text: text.as_ref().to_string(), text: text.into(),
color: None, color: None,
padding: None, padding: None,
widget_type: WidgetType::Button widget_type: WidgetType::Button
@@ -35,9 +37,9 @@ impl PaddedWidget {
} }
} }
pub fn label(text: String) -> Self { pub fn label(text: impl Into<String>) -> Self {
Self { Self {
text, text: text.into(),
color: None, color: None,
padding: None, padding: None,
widget_type: WidgetType::Label widget_type: WidgetType::Label
@@ -57,7 +59,12 @@ impl PaddedWidget {
pub fn ui(&mut self, ui: &mut egui::Ui) -> egui::Response { pub fn ui(&mut self, ui: &mut egui::Ui) -> egui::Response {
let text: egui::WidgetText = self.text.clone().into(); let text: egui::WidgetText = self.text.clone().into();
let galley = text.into_galley(ui, None, f32::INFINITY, egui::TextStyle::Button); 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()); let (rect, response) = ui.allocate_exact_size(desired_size, egui::Sense::click());
@@ -77,116 +84,38 @@ impl PaddedWidget {
egui::StrokeKind::Inside, egui::StrokeKind::Inside,
); );
let text_pos = rect.center() - galley.size() / 2.0; if let WidgetType::Indicator = self.widget_type {
ui.painter().galley(text_pos, galley, self.color.unwrap_or(visuals.fg_stroke.color)); 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 response
} }
} }
// pub fn frame_button(ui: &mut egui::Ui, text: impl Into<egui::WidgetText>, 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<egui::WidgetText>, 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<R>(
ui: &mut egui::Ui,
padding: egui::Vec2,
add_contents: impl FnOnce(&mut egui::Ui) -> R,
) -> egui::InnerResponse<R> {
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 { 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 color(&self) -> egui::Color32;
fn label(&self) -> String; fn label(&self) -> String;
} }
impl StatusIndicator for RunningState { impl StatusIndicator for RunningState {
fn color(&self) -> egui::Color32 { fn color(&self) -> egui::Color32 {
match self { match self {
RunningState::Running => egui::Color32::from_rgb(0x4c, 0xaf, 0x50), RunningState::Running => egui::Color32::GREEN,
RunningState::Paused => egui::Color32::from_rgb(0xff, 0xc1, 0x07), RunningState::Paused => egui::Color32::YELLOW,
RunningState::Halted => egui::Color32::from_rgb(0xff, 0xc1, 0x07), RunningState::Halted => egui::Color32::ORANGE,
RunningState::Breakpoint(_) => egui::Color32::from_rgb(0xe5, 0x39, 0x35), RunningState::Breakpoint(_) => egui::Color32::RED,
RunningState::Shutdown => egui::Color32::from_rgb(0xff, 0xc1, 0x07), RunningState::Shutdown => egui::Color32::BLUE,
} }
} }
@@ -199,4 +128,121 @@ impl StatusIndicator for RunningState {
RunningState::Shutdown => "Shutdown".into(), 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<egui::WidgetText>,
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 }
} }
@@ -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
}