added zxq5_egui crate, a crate for packaging together my UI components so i can reuse them across projects.

- added buttons/tabs
- added a simple file tree (work in progress)
This commit is contained in:
2026-08-02 02:58:28 +01:00
parent 43f8340f0d
commit 59c26564d3
12 changed files with 446 additions and 287 deletions
+1
View File
@@ -5,3 +5,4 @@ version.workspace = true
authors.workspace = true
[dependencies]
egui = "0.35.0"
+103
View File
@@ -0,0 +1,103 @@
use egui::{Color32, Vec2};
pub struct PaddedWidget {
text: String,
color: Option<Color32>,
padding: Option<Vec2>,
widget_type: WidgetType
}
enum WidgetType {
Button,
Indicator,
Label
}
impl PaddedWidget {
pub fn button(text: impl Into<String>) -> 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<String>) -> 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;
}
+121
View File
@@ -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<FileTree>,
},
Leaf {
filename: PathBuf,
},
}
impl Eq for FileTree {}
impl PartialEq<Self> for FileTree {
fn eq(&self, other: &Self) -> bool {
matches!(
(self, other),
(Self::Branch { .. }, Self::Branch { .. }) | (Self::Leaf { .. }, Self::Leaf { .. })
)
}
}
impl PartialOrd<Self> for FileTree {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
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<A> {
Clicked(PathBuf),
ContextAction(A),
}
impl FileTree {
pub fn ui<A>(
&self,
ui: &mut egui::Ui,
ctx: &mut impl FnMut(&mut egui::Ui, &Path) -> Option<A>,
) -> InnerResponse<Option<FileTreeEvent<A>>> {
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<A>(
&self,
ui: &mut egui::Ui,
action: &mut Option<FileTreeEvent<A>>,
ctx: &mut impl FnMut(&mut egui::Ui, &Path) -> Option<A>,
) {
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<Self> = 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(),
},
}
}
}
+1
View File
@@ -1,2 +1,3 @@
pub mod buttons;
pub mod tabs;
pub mod file_tree;
+116
View File
@@ -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<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 }
}