From 9afbfd79abdf50736ca1d801878266017d61ae34 Mon Sep 17 00:00:00 2001 From: zxq5 Date: Mon, 28 Sep 2026 23:22:41 +0100 Subject: [PATCH] initial commit --- Cargo.toml | 8 ++++ README.md | 10 ++++ src/buttons.rs | 103 +++++++++++++++++++++++++++++++++++++++++ src/lib.rs | 7 +++ src/tabs.rs | 116 +++++++++++++++++++++++++++++++++++++++++++++++ src/tree/file.rs | 56 +++++++++++++++++++++++ src/tree/mod.rs | 110 ++++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 410 insertions(+) create mode 100644 Cargo.toml create mode 100644 README.md create mode 100644 src/buttons.rs create mode 100644 src/lib.rs create mode 100644 src/tabs.rs create mode 100644 src/tree/file.rs create mode 100644 src/tree/mod.rs diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..633f5f2 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "zxq5_egui" +edition.workspace = true +version.workspace = true +authors.workspace = true + +[dependencies] +egui = "0.36.1" diff --git a/README.md b/README.md new file mode 100644 index 0000000..4b0d482 --- /dev/null +++ b/README.md @@ -0,0 +1,10 @@ + +# zxq5_egui +A set of UI components for the egui UI framework that may be useful across all of my personal projects. + +## Features +- buttons & labels styled to my personal liking, meant to achieve a clean modern look. + +## TODO: +- File-tree for viewing files within a directory and performing simple directory operations from within the project. + - this will be useful for the DSA user interface and maybe for Worldcoder if I revive that project in the future. \ No newline at end of file diff --git a/src/buttons.rs b/src/buttons.rs new file mode 100644 index 0000000..8faa161 --- /dev/null +++ b/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/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..85b8083 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,7 @@ +#![feature(trait_alias)] +#![feature(str_as_str)] +#![feature(inherent_associated_types)] + +pub mod buttons; +pub mod tabs; +pub mod tree; diff --git a/src/tabs.rs b/src/tabs.rs new file mode 100644 index 0000000..10aebf9 --- /dev/null +++ b/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 diff --git a/src/tree/file.rs b/src/tree/file.rs new file mode 100644 index 0000000..2dafb6e --- /dev/null +++ b/src/tree/file.rs @@ -0,0 +1,56 @@ +use super::{Node, TreeView}; +use std::path::{Path, PathBuf}; + +#[derive(Clone, PartialOrd, Ord, Eq, PartialEq)] +pub enum FileTree { + Directory { + path: PathBuf, + children: Vec, + }, + File { + path: PathBuf, + }, +} + +impl FileTree { + pub fn build(path: &Path) -> Self { + match path.is_dir() { + true => Self::Directory { + 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::File { + path: path.to_path_buf(), + }, + } + } + + pub fn filename(&self) -> &Path { + match self { + Self::Directory { path, .. } | Self::File { path } => path, + } + } +} + +impl TreeView for FileTree { + fn node(&self) -> Node<'_, Self> { + match self { + Self::Directory { path, children } => { + Node::Branch(path.file_name().unwrap().to_string_lossy(), children) + } + Self::File { path: filename } => { + Node::Leaf(filename.file_name().unwrap().to_string_lossy()) + } + } + } +} diff --git a/src/tree/mod.rs b/src/tree/mod.rs new file mode 100644 index 0000000..68c0ab2 --- /dev/null +++ b/src/tree/mod.rs @@ -0,0 +1,110 @@ +use egui::response::InnerResponse; +use egui::{Ui, Widget}; +use std::borrow::Cow; + +pub mod file; + +pub trait TreeView: Sized + Clone { + fn node(&self) -> Node<'_, Self>; +} + +pub enum TreeEvent<'t, A, T: TreeView> { + Clicked(&'t T), + ContextAction(A, &'t T), +} + +#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd)] +pub enum Node<'t, T: TreeView> { + Leaf(Cow<'t, str>), + Branch(Cow<'t, str>, &'t Vec), +} + +struct CtxMenu(pub Box Option>); + +impl CtxMenu { + pub fn new(f: impl FnMut(&mut Ui, &Tree) -> Option + 'static) -> Self { + Self(Box::new(f)) + } + + pub fn ui<'t>(&mut self, ui: &mut Ui, tree: &'t Tree) -> Option> { + self.0(ui, tree).and_then(|a| Some(TreeEvent::ContextAction(a, tree))) + } +} + +pub struct MenuTree { + root: Option, + context: Option>, +} + +impl MenuTree +where + Tree: TreeView, +{ + pub fn new() -> Self { + Self { + root: None, + context: None, + } + } + + pub fn root(mut self, tree: Option) -> Self { + self.root = tree.clone(); + self + } + + pub fn ctx(mut self, ctx: impl FnMut(&mut Ui, &Tree) -> Option + 'static) -> Self { + self.context = Some(CtxMenu::new(ctx)); + self + } + + pub fn ui(&mut self, ui: &mut Ui) -> InnerResponse>> { + let mut action = None; + + let frame_response = egui::Frame::new().show(ui, |ui| { + egui::ScrollArea::both() + .auto_shrink([false; 2]) + .show(ui, |ui| { + let Some(root) = &self.root else { + return; + }; + + let Some(ctx) = &mut self.context else { + return; + }; + + Self::ui_inner(ui, &mut action, ctx, root); + }) + }); + InnerResponse::new(action, frame_response.response) + } + + fn ui_inner<'ctx, 't>( + ui: &mut Ui, + action: &mut Option>, + ctx: &mut CtxMenu, + tree: &'t Tree, + ) { + match tree.node() { + Node::Leaf(name) => { + let response = egui::Button::new(name.as_str()).frame(false).ui(ui); + + response.context_menu(|ui| *action = ctx.ui(ui, tree)); + + if response.clicked() { + *action = Some(TreeEvent::Clicked(tree)); + } + } + Node::Branch(name, children) => { + let show_response = egui::CollapsingHeader::new(name.as_str()).show(ui, |ui| { + for child in children.iter() { + Self::ui_inner(ui, action, ctx, child); + } + }); + + show_response + .header_response + .context_menu(|ui| *action = ctx.ui(ui, tree)); + } + } + } +}