unified tab style in UI
This commit is contained in:
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<Pane> for TileBehavior {
|
||||
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 {
|
||||
pane.title()
|
||||
}
|
||||
@@ -86,7 +109,6 @@ impl Behavior<Pane> for TileBehavior {
|
||||
true
|
||||
}
|
||||
|
||||
|
||||
fn tab_bar_height(&self, _style: &egui::Style) -> f32 {
|
||||
36.0 // taller tab row = more vertical padding around the label
|
||||
}
|
||||
|
||||
@@ -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<dyn FnOnce(&mut ProcessManager) + Send>;
|
||||
|
||||
@@ -62,15 +64,15 @@ impl ProcessManager {
|
||||
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) {
|
||||
let handle = ProcessHandle::spawn(self.new_id(), "process", process, on_complete);
|
||||
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(), 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()
|
||||
|
||||
@@ -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<str>) -> Self {
|
||||
pub fn button(text: impl Into<String>) -> 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<String>) -> 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<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 {
|
||||
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<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
|
||||
}
|
||||
Reference in New Issue
Block a user