initial commit
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
[package]
|
||||
name = "zxq5_egui"
|
||||
edition.workspace = true
|
||||
version.workspace = true
|
||||
authors.workspace = true
|
||||
|
||||
[dependencies]
|
||||
egui = "0.36.1"
|
||||
@@ -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.
|
||||
+103
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
#![feature(trait_alias)]
|
||||
#![feature(str_as_str)]
|
||||
#![feature(inherent_associated_types)]
|
||||
|
||||
pub mod buttons;
|
||||
pub mod tabs;
|
||||
pub mod tree;
|
||||
+116
@@ -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 }
|
||||
}
|
||||
@@ -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<FileTree>,
|
||||
},
|
||||
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<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::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())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+110
@@ -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<T>),
|
||||
}
|
||||
|
||||
struct CtxMenu<A, T: TreeView>(pub Box<dyn FnMut(&mut Ui, &T) -> Option<A>>);
|
||||
|
||||
impl<Action, Tree: TreeView> CtxMenu<Action, Tree> {
|
||||
pub fn new(f: impl FnMut(&mut Ui, &Tree) -> Option<Action> + 'static) -> Self {
|
||||
Self(Box::new(f))
|
||||
}
|
||||
|
||||
pub fn ui<'t>(&mut self, ui: &mut Ui, tree: &'t Tree) -> Option<TreeEvent<'t, Action, Tree>> {
|
||||
self.0(ui, tree).and_then(|a| Some(TreeEvent::ContextAction(a, tree)))
|
||||
}
|
||||
}
|
||||
|
||||
pub struct MenuTree<A, T: TreeView> {
|
||||
root: Option<T>,
|
||||
context: Option<CtxMenu<A, T>>,
|
||||
}
|
||||
|
||||
impl<Action, Tree> MenuTree<Action, Tree>
|
||||
where
|
||||
Tree: TreeView,
|
||||
{
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
root: None,
|
||||
context: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn root(mut self, tree: Option<Tree>) -> Self {
|
||||
self.root = tree.clone();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn ctx(mut self, ctx: impl FnMut(&mut Ui, &Tree) -> Option<Action> + 'static) -> Self {
|
||||
self.context = Some(CtxMenu::new(ctx));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn ui(&mut self, ui: &mut Ui) -> InnerResponse<Option<TreeEvent<'_, Action, Tree>>> {
|
||||
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<TreeEvent<'t, Action, Tree>>,
|
||||
ctx: &mut CtxMenu<Action, Tree>,
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user