feat(bar): add widget grouping options

This commit adds various widget grouping and transparency options to
komorebi-bar, and is comprised of the individual commits listed below,
worked on in PR #1108, squashed into one.

e8f5952abb
* adding RenderConfig, and some test frames on widgets

0a5e0a4c0a
* no clone

a5a7d6906c
* comment

6a91dd46cd
* ignore unused

80f0214e47
* Group enum, Copy RenderConfig

fbe5e2c1f7
* Group -> Grouping

ce49b433f9
* GroupingConfig

f446a6a45f
* "fmt --check" fix (thanks VS)

d188222be7
* added widget grouping and group module

1008ec2031
* rounding from settings, and apply_on_side

7fff6d29a9
* dereferencing

655e8ce4c1
* AlphaColour, transparency, bar background, more grouping config options

cba0fcd882
* added RoundingConfig

ec5f7dc82d
* handling grouping edge case for komorebi focus window

12117b832b
* changed default values

645c46beb8
* background color using theme color, AlphaColour.to_color32_or, updating json format for Grouping and RoundingConfig

10d2ab21c7
* hot-reload on grouping

d88774328a
* grouping correction on init

2cd237fd0d
* added shadow to grouping, optional width on grouping stroke

4f4b617f26
* grouping on bar, converting AlphaColour from_rgba_unmultiplied, simplified grouping

3808fcec8f
* widget rounding based on grouping, atomic background color, simplified config, style on grouping

be45d14f6d
* renamed Side to Alignment, group spacing

be45d14f6d
* proper widget spacing based on alignment

b43a5bda69
* added widget_spacing to config

c18e5f4dbe
* test commit

cba2b2f7ac
* refactoring of render and grouping, widget spacing WIP

9311cb00ec
* simplify no_spacing

36c267246b
* correct spacing on komorebi and network widgets (WIP)

85a41bf5b2
* correct widget spacing on all widgets

50b49ccf69
* refactoring widget spacing

9ec67ad988
* account for ui item_spacing when setting the widget_spacing

e88a2fd9c0
* format
This commit is contained in:
CtByte
2024-11-19 04:55:21 +01:00
committed by LGUG2Z
parent e4e94fd1a6
commit 219fa8e14f
15 changed files with 719 additions and 363 deletions

View File

@@ -5,6 +5,10 @@ use crate::config::PositionConfig;
use crate::komorebi::Komorebi; use crate::komorebi::Komorebi;
use crate::komorebi::KomorebiNotificationState; use crate::komorebi::KomorebiNotificationState;
use crate::process_hwnd; use crate::process_hwnd;
use crate::render::Color32Ext;
use crate::render::Grouping;
use crate::render::RenderConfig;
use crate::render::RenderExt;
use crate::widget::BarWidget; use crate::widget::BarWidget;
use crate::widget::WidgetConfig; use crate::widget::WidgetConfig;
use crate::BAR_HEIGHT; use crate::BAR_HEIGHT;
@@ -24,8 +28,10 @@ use eframe::egui::FontId;
use eframe::egui::Frame; use eframe::egui::Frame;
use eframe::egui::Layout; use eframe::egui::Layout;
use eframe::egui::Margin; use eframe::egui::Margin;
use eframe::egui::Rgba;
use eframe::egui::Style; use eframe::egui::Style;
use eframe::egui::TextStyle; use eframe::egui::TextStyle;
use eframe::egui::Visuals;
use font_loader::system_fonts; use font_loader::system_fonts;
use font_loader::system_fonts::FontPropertyBuilder; use font_loader::system_fonts::FontPropertyBuilder;
use komorebi_client::KomorebiTheme; use komorebi_client::KomorebiTheme;
@@ -41,6 +47,7 @@ use std::sync::Arc;
pub struct Komobar { pub struct Komobar {
pub config: Arc<KomobarConfig>, pub config: Arc<KomobarConfig>,
pub render_config: Rc<RefCell<RenderConfig>>,
pub komorebi_notification_state: Option<Rc<RefCell<KomorebiNotificationState>>>, pub komorebi_notification_state: Option<Rc<RefCell<KomorebiNotificationState>>>,
pub left_widgets: Vec<Box<dyn BarWidget>>, pub left_widgets: Vec<Box<dyn BarWidget>>,
pub right_widgets: Vec<Box<dyn BarWidget>>, pub right_widgets: Vec<Box<dyn BarWidget>>,
@@ -237,6 +244,30 @@ impl Komobar {
} }
} }
// apply rounding to the widgets
if let Some(
Grouping::Bar(config) | Grouping::Alignment(config) | Grouping::Widget(config),
) = &config.grouping
{
if let Some(rounding) = config.rounding {
ctx.style_mut(|style| {
style.visuals.widgets.noninteractive.rounding = rounding.into();
style.visuals.widgets.inactive.rounding = rounding.into();
style.visuals.widgets.hovered.rounding = rounding.into();
style.visuals.widgets.active.rounding = rounding.into();
style.visuals.widgets.open.rounding = rounding.into();
});
}
}
let theme_color = *self.bg_color.borrow();
self.render_config
.replace(config.new_renderconfig(theme_color));
self.bg_color
.replace(theme_color.try_apply_alpha(self.config.transparency_alpha));
if let Some(font_size) = &config.font_size { if let Some(font_size) = &config.font_size {
tracing::info!("attempting to set custom font size: {font_size}"); tracing::info!("attempting to set custom font size: {font_size}");
Self::set_font_size(ctx, *font_size); Self::set_font_size(ctx, *font_size);
@@ -251,7 +282,7 @@ impl Komobar {
if let WidgetConfig::Komorebi(config) = widget_config { if let WidgetConfig::Komorebi(config) = widget_config {
komorebi_widget = Some(Komorebi::from(config)); komorebi_widget = Some(Komorebi::from(config));
komorebi_widget_idx = Some(idx); komorebi_widget_idx = Some(idx);
side = Some(Side::Left); side = Some(Alignment::Left);
} }
} }
@@ -259,7 +290,7 @@ impl Komobar {
if let WidgetConfig::Komorebi(config) = widget_config { if let WidgetConfig::Komorebi(config) = widget_config {
komorebi_widget = Some(Komorebi::from(config)); komorebi_widget = Some(Komorebi::from(config));
komorebi_widget_idx = Some(idx); komorebi_widget_idx = Some(idx);
side = Some(Side::Right); side = Some(Alignment::Right);
} }
} }
@@ -293,8 +324,8 @@ impl Komobar {
let boxed: Box<dyn BarWidget> = Box::new(widget); let boxed: Box<dyn BarWidget> = Box::new(widget);
match side { match side {
Side::Left => left_widgets[idx] = boxed, Alignment::Left => left_widgets[idx] = boxed,
Side::Right => right_widgets[idx] = boxed, Alignment::Right => right_widgets[idx] = boxed,
} }
} }
@@ -307,6 +338,7 @@ impl Komobar {
self.komorebi_notification_state = komorebi_notification_state; self.komorebi_notification_state = komorebi_notification_state;
} }
pub fn new( pub fn new(
cc: &eframe::CreationContext<'_>, cc: &eframe::CreationContext<'_>,
rx_gui: Receiver<komorebi_client::Notification>, rx_gui: Receiver<komorebi_client::Notification>,
@@ -315,6 +347,7 @@ impl Komobar {
) -> Self { ) -> Self {
let mut komobar = Self { let mut komobar = Self {
config: config.clone(), config: config.clone(),
render_config: Rc::new(RefCell::new(RenderConfig::new())),
komorebi_notification_state: None, komorebi_notification_state: None,
left_widgets: vec![], left_widgets: vec![],
right_widgets: vec![], right_widgets: vec![],
@@ -385,13 +418,10 @@ impl Komobar {
} }
} }
impl eframe::App for Komobar { impl eframe::App for Komobar {
// TODO: I think this is needed for transparency?? // Needed for transparency
// fn clear_color(&self, _visuals: &Visuals) -> [f32; 4] { fn clear_color(&self, _visuals: &Visuals) -> [f32; 4] {
// egui::Rgba::TRANSPARENT.to_array() Rgba::TRANSPARENT.to_array()
// let mut background = Color32::from_gray(18).to_normalized_gamma_f32(); }
// background[3] = 0.9;
// background
// }
fn update(&mut self, ctx: &Context, _frame: &mut eframe::Frame) { fn update(&mut self, ctx: &Context, _frame: &mut eframe::Frame) {
if self.scale_factor != ctx.native_pixels_per_point().unwrap_or(1.0) { if self.scale_factor != ctx.native_pixels_per_point().unwrap_or(1.0) {
@@ -433,18 +463,35 @@ impl eframe::App for Komobar {
Frame::none().fill(*self.bg_color.borrow()) Frame::none().fill(*self.bg_color.borrow())
}; };
CentralPanel::default().frame(frame).show(ctx, |ui| { let mut render_config = self.render_config.borrow_mut();
ui.horizontal_centered(|ui| {
ui.with_layout(Layout::left_to_right(Align::Center), |ui| {
for w in &mut self.left_widgets {
w.render(ctx, ui);
}
});
ui.with_layout(Layout::right_to_left(Align::Center), |ui| { CentralPanel::default().frame(frame).show(ctx, |ui| {
for w in &mut self.right_widgets { // Apply grouping logic for the bar as a whole
w.render(ctx, ui); render_config.clone().apply_on_bar(ui, |ui| {
} ui.horizontal_centered(|ui| {
// Left-aligned widgets layout
ui.with_layout(Layout::left_to_right(Align::Center), |ui| {
let mut render_conf = *render_config;
render_conf.alignment = Some(Alignment::Left);
render_config.apply_on_alignment(ui, |ui| {
for w in &mut self.left_widgets {
w.render(ctx, ui, &mut render_conf);
}
});
});
// Right-aligned widgets layout
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
let mut render_conf = *render_config;
render_conf.alignment = Some(Alignment::Right);
render_config.apply_on_alignment(ui, |ui| {
for w in &mut self.right_widgets {
w.render(ctx, ui, &mut render_conf);
}
});
})
}) })
}) })
}); });
@@ -452,7 +499,7 @@ impl eframe::App for Komobar {
} }
#[derive(Copy, Clone)] #[derive(Copy, Clone)]
enum Side { pub enum Alignment {
Left, Left,
Right, Right,
} }

View File

@@ -1,6 +1,6 @@
use crate::config::LabelPrefix; use crate::config::LabelPrefix;
use crate::render::RenderConfig;
use crate::widget::BarWidget; use crate::widget::BarWidget;
use crate::WIDGET_SPACING;
use eframe::egui::text::LayoutJob; use eframe::egui::text::LayoutJob;
use eframe::egui::Context; use eframe::egui::Context;
use eframe::egui::FontId; use eframe::egui::FontId;
@@ -115,7 +115,7 @@ impl Battery {
} }
impl BarWidget for Battery { impl BarWidget for Battery {
fn render(&mut self, ctx: &Context, ui: &mut Ui) { fn render(&mut self, ctx: &Context, ui: &mut Ui, config: &mut RenderConfig) {
if self.enable { if self.enable {
let output = self.output(); let output = self.output();
if !output.is_empty() { if !output.is_empty() {
@@ -147,14 +147,14 @@ impl BarWidget for Battery {
TextFormat::simple(font_id, ctx.style().visuals.text_color()), TextFormat::simple(font_id, ctx.style().visuals.text_color()),
); );
ui.add( config.apply_on_widget(true, ui, |ui| {
Label::new(layout_job) ui.add(
.selectable(false) Label::new(layout_job)
.sense(Sense::click()), .selectable(false)
); .sense(Sense::click()),
);
});
} }
ui.add_space(WIDGET_SPACING);
} }
} }
} }

View File

@@ -1,3 +1,4 @@
use crate::render::Grouping;
use crate::widget::WidgetConfig; use crate::widget::WidgetConfig;
use eframe::egui::Pos2; use eframe::egui::Pos2;
use eframe::egui::TextBuffer; use eframe::egui::TextBuffer;
@@ -28,6 +29,12 @@ pub struct KomobarConfig {
pub max_label_width: Option<f32>, pub max_label_width: Option<f32>,
/// Theme /// Theme
pub theme: Option<KomobarTheme>, pub theme: Option<KomobarTheme>,
/// Alpha value for the color transparency [[0-255]] (default: 200)
pub transparency_alpha: Option<u8>,
/// Spacing between widgets (default: 10.0)
pub widget_spacing: Option<f32>,
/// Visual grouping for widgets
pub grouping: Option<Grouping>,
/// Left side widgets (ordered left-to-right) /// Left side widgets (ordered left-to-right)
pub left_widgets: Vec<WidgetConfig>, pub left_widgets: Vec<WidgetConfig>,
/// Right side widgets (ordered left-to-right) /// Right side widgets (ordered left-to-right)

View File

@@ -1,6 +1,6 @@
use crate::config::LabelPrefix; use crate::config::LabelPrefix;
use crate::render::RenderConfig;
use crate::widget::BarWidget; use crate::widget::BarWidget;
use crate::WIDGET_SPACING;
use eframe::egui::text::LayoutJob; use eframe::egui::text::LayoutJob;
use eframe::egui::Context; use eframe::egui::Context;
use eframe::egui::FontId; use eframe::egui::FontId;
@@ -70,7 +70,7 @@ impl Cpu {
} }
impl BarWidget for Cpu { impl BarWidget for Cpu {
fn render(&mut self, ctx: &Context, ui: &mut Ui) { fn render(&mut self, ctx: &Context, ui: &mut Ui, config: &mut RenderConfig) {
if self.enable { if self.enable {
let output = self.output(); let output = self.output();
if !output.is_empty() { if !output.is_empty() {
@@ -99,22 +99,23 @@ impl BarWidget for Cpu {
TextFormat::simple(font_id, ctx.style().visuals.text_color()), TextFormat::simple(font_id, ctx.style().visuals.text_color()),
); );
if ui config.apply_on_widget(true, ui, |ui| {
.add( if ui
Label::new(layout_job) .add(
.selectable(false) Label::new(layout_job)
.sense(Sense::click()), .selectable(false)
) .sense(Sense::click()),
.clicked() )
{ .clicked()
if let Err(error) = Command::new("cmd.exe").args(["/C", "taskmgr.exe"]).spawn()
{ {
eprintln!("{}", error) if let Err(error) =
Command::new("cmd.exe").args(["/C", "taskmgr.exe"]).spawn()
{
eprintln!("{}", error)
}
} }
} });
} }
ui.add_space(WIDGET_SPACING);
} }
} }
} }

View File

@@ -1,6 +1,6 @@
use crate::config::LabelPrefix; use crate::config::LabelPrefix;
use crate::render::RenderConfig;
use crate::widget::BarWidget; use crate::widget::BarWidget;
use crate::WIDGET_SPACING;
use eframe::egui::text::LayoutJob; use eframe::egui::text::LayoutJob;
use eframe::egui::Context; use eframe::egui::Context;
use eframe::egui::FontId; use eframe::egui::FontId;
@@ -86,7 +86,7 @@ impl Date {
} }
impl BarWidget for Date { impl BarWidget for Date {
fn render(&mut self, ctx: &Context, ui: &mut Ui) { fn render(&mut self, ctx: &Context, ui: &mut Ui, config: &mut RenderConfig) {
if self.enable { if self.enable {
let mut output = self.output(); let mut output = self.output();
if !output.is_empty() { if !output.is_empty() {
@@ -119,19 +119,19 @@ impl BarWidget for Date {
TextFormat::simple(font_id, ctx.style().visuals.text_color()), TextFormat::simple(font_id, ctx.style().visuals.text_color()),
); );
if ui config.apply_on_widget(true, ui, |ui| {
.add( if ui
Label::new(WidgetText::LayoutJob(layout_job.clone())) .add(
.selectable(false) Label::new(WidgetText::LayoutJob(layout_job.clone()))
.sense(Sense::click()), .selectable(false)
) .sense(Sense::click()),
.clicked() )
{ .clicked()
self.format.next() {
} self.format.next()
}
});
} }
ui.add_space(WIDGET_SPACING);
} }
} }
} }

View File

@@ -1,9 +1,9 @@
use crate::bar::apply_theme; use crate::bar::apply_theme;
use crate::config::KomobarTheme; use crate::config::KomobarTheme;
use crate::render::RenderConfig;
use crate::ui::CustomUi; use crate::ui::CustomUi;
use crate::widget::BarWidget; use crate::widget::BarWidget;
use crate::MAX_LABEL_WIDTH; use crate::MAX_LABEL_WIDTH;
use crate::WIDGET_SPACING;
use crossbeam_channel::Receiver; use crossbeam_channel::Receiver;
use crossbeam_channel::TryRecvError; use crossbeam_channel::TryRecvError;
use eframe::egui::text::LayoutJob; use eframe::egui::text::LayoutJob;
@@ -122,102 +122,123 @@ pub struct Komorebi {
} }
impl BarWidget for Komorebi { impl BarWidget for Komorebi {
fn render(&mut self, ctx: &Context, ui: &mut Ui) { fn render(&mut self, ctx: &Context, ui: &mut Ui, config: &mut RenderConfig) {
let mut komorebi_notification_state = self.komorebi_notification_state.borrow_mut(); let mut komorebi_notification_state = self.komorebi_notification_state.borrow_mut();
if self.workspaces.enable { if self.workspaces.enable {
let mut update = None; let mut update = None;
for (i, (ws, should_show)) in komorebi_notification_state.workspaces.iter().enumerate() // NOTE: There should always be at least one workspace if the bar is connected to komorebi.
{ config.apply_on_widget(false, ui, |ui| {
if *should_show for (i, (ws, should_show)) in
&& ui komorebi_notification_state.workspaces.iter().enumerate()
.add(SelectableLabel::new(
komorebi_notification_state.selected_workspace.eq(ws),
ws.to_string(),
))
.clicked()
{ {
update = Some(ws.to_string()); if *should_show
let mut proceed = true; && ui
.add(SelectableLabel::new(
if komorebi_client::send_message(&SocketMessage::MouseFollowsFocus(false)) komorebi_notification_state.selected_workspace.eq(ws),
.is_err() ws.to_string(),
))
.clicked()
{ {
tracing::error!("could not send message to komorebi: MouseFollowsFocus"); update = Some(ws.to_string());
proceed = false; let mut proceed = true;
}
if proceed if komorebi_client::send_message(&SocketMessage::MouseFollowsFocus(false))
&& komorebi_client::send_message(&SocketMessage::FocusWorkspaceNumber(i))
.is_err() .is_err()
{ {
tracing::error!("could not send message to komorebi: FocusWorkspaceNumber"); tracing::error!(
proceed = false; "could not send message to komorebi: MouseFollowsFocus"
} );
proceed = false;
}
if proceed if proceed
&& komorebi_client::send_message(&SocketMessage::MouseFollowsFocus( && komorebi_client::send_message(&SocketMessage::FocusWorkspaceNumber(
komorebi_notification_state.mouse_follows_focus, i,
)) ))
.is_err()
{
tracing::error!("could not send message to komorebi: MouseFollowsFocus");
proceed = false;
}
if proceed
&& komorebi_client::send_message(&SocketMessage::RetileWithResizeDimensions)
.is_err() .is_err()
{ {
tracing::error!("could not send message to komorebi: Retile"); tracing::error!(
"could not send message to komorebi: FocusWorkspaceNumber"
);
proceed = false;
}
if proceed
&& komorebi_client::send_message(&SocketMessage::MouseFollowsFocus(
komorebi_notification_state.mouse_follows_focus,
))
.is_err()
{
tracing::error!(
"could not send message to komorebi: MouseFollowsFocus"
);
proceed = false;
}
if proceed
&& komorebi_client::send_message(
&SocketMessage::RetileWithResizeDimensions,
)
.is_err()
{
tracing::error!("could not send message to komorebi: Retile");
}
} }
} }
} });
if let Some(update) = update { if let Some(update) = update {
komorebi_notification_state.selected_workspace = update; komorebi_notification_state.selected_workspace = update;
} }
ui.add_space(WIDGET_SPACING);
} }
if let Some(layout) = self.layout { if let Some(layout) = self.layout {
if layout.enable { if layout.enable {
if ui config.apply_on_widget(true, ui, |ui| {
.add( if ui
Label::new(komorebi_notification_state.layout.to_string()) .add(
.selectable(false) Label::new(komorebi_notification_state.layout.to_string())
.sense(Sense::click()), .selectable(false)
) .sense(Sense::click()),
.clicked() )
{ .clicked()
match komorebi_notification_state.layout { {
KomorebiLayout::Default(_) => { match komorebi_notification_state.layout {
if komorebi_client::send_message(&SocketMessage::CycleLayout( KomorebiLayout::Default(_) => {
CycleDirection::Next, if komorebi_client::send_message(&SocketMessage::CycleLayout(
)) CycleDirection::Next,
.is_err() ))
{ .is_err()
tracing::error!("could not send message to komorebi: CycleLayout"); {
tracing::error!(
"could not send message to komorebi: CycleLayout"
);
}
} }
} KomorebiLayout::Floating => {
KomorebiLayout::Floating => { if komorebi_client::send_message(&SocketMessage::ToggleTiling)
if komorebi_client::send_message(&SocketMessage::ToggleTiling).is_err() .is_err()
{ {
tracing::error!("could not send message to komorebi: ToggleTiling"); tracing::error!(
"could not send message to komorebi: ToggleTiling"
);
}
} }
} KomorebiLayout::Paused => {
KomorebiLayout::Paused => { if komorebi_client::send_message(&SocketMessage::TogglePause)
if komorebi_client::send_message(&SocketMessage::TogglePause).is_err() { .is_err()
tracing::error!("could not send message to komorebi: TogglePause"); {
tracing::error!(
"could not send message to komorebi: TogglePause"
);
}
} }
KomorebiLayout::Custom => {}
} }
KomorebiLayout::Custom => {}
} }
} });
ui.add_space(WIDGET_SPACING);
} }
} }
@@ -225,170 +246,174 @@ impl BarWidget for Komorebi {
if configuration_switcher.enable { if configuration_switcher.enable {
for (name, location) in configuration_switcher.configurations.iter() { for (name, location) in configuration_switcher.configurations.iter() {
let path = PathBuf::from(location); let path = PathBuf::from(location);
if path.is_file() if path.is_file() {
&& ui config.apply_on_widget(true, ui,|ui|{
if ui
.add(Label::new(name).selectable(false).sense(Sense::click())) .add(Label::new(name).selectable(false).sense(Sense::click()))
.clicked() .clicked()
{
let canonicalized = dunce::canonicalize(path.clone()).unwrap_or(path);
let mut proceed = true;
if komorebi_client::send_message(&SocketMessage::ReplaceConfiguration(
canonicalized,
))
.is_err()
{ {
tracing::error!( let canonicalized = dunce::canonicalize(path.clone()).unwrap_or(path);
"could not send message to komorebi: ReplaceConfiguration" let mut proceed = true;
); if komorebi_client::send_message(&SocketMessage::ReplaceConfiguration(
proceed = false; canonicalized,
} ))
.is_err()
{
tracing::error!(
"could not send message to komorebi: ReplaceConfiguration"
);
proceed = false;
}
if let Some(rect) = komorebi_notification_state.work_area_offset { if let Some(rect) = komorebi_notification_state.work_area_offset {
if proceed { if proceed {
match komorebi_client::send_query(&SocketMessage::Query( match komorebi_client::send_query(&SocketMessage::Query(
komorebi_client::StateQuery::FocusedMonitorIndex, komorebi_client::StateQuery::FocusedMonitorIndex,
)) { )) {
Ok(idx) => { Ok(idx) => {
if let Ok(monitor_idx) = idx.parse::<usize>() { if let Ok(monitor_idx) = idx.parse::<usize>() {
if komorebi_client::send_message( if komorebi_client::send_message(
&SocketMessage::MonitorWorkAreaOffset( &SocketMessage::MonitorWorkAreaOffset(
monitor_idx, monitor_idx,
rect, rect,
), ),
) )
.is_err() .is_err()
{ {
tracing::error!( tracing::error!(
"could not send message to komorebi: MonitorWorkAreaOffset" "could not send message to komorebi: MonitorWorkAreaOffset"
); );
}
} }
} }
} Err(_) => {
Err(_) => { tracing::error!(
tracing::error!( "could not send message to komorebi: Query"
"could not send message to komorebi: Query" );
); }
} }
} }
} }
} }});
} }
} }
ui.add_space(WIDGET_SPACING);
} }
} }
if let Some(focused_window) = self.focused_window { if let Some(focused_window) = self.focused_window {
if focused_window.enable { if focused_window.enable {
let titles = &komorebi_notification_state.focused_container_information.0; let titles = &komorebi_notification_state.focused_container_information.0;
let icons = &komorebi_notification_state.focused_container_information.1; if !titles.is_empty() {
let focused_window_idx = config.apply_on_widget(true, ui, |ui| {
komorebi_notification_state.focused_container_information.2; let icons = &komorebi_notification_state.focused_container_information.1;
let focused_window_idx =
komorebi_notification_state.focused_container_information.2;
let iter = titles.iter().zip(icons.iter()); let iter = titles.iter().zip(icons.iter());
for (i, (title, icon)) in iter.enumerate() { for (i, (title, icon)) in iter.enumerate() {
if focused_window.show_icon { if focused_window.show_icon {
if let Some(img) = icon { if let Some(img) = icon {
ui.add( ui.add(
Image::from(&img_to_texture(ctx, img)) Image::from(&img_to_texture(ctx, img))
.maintain_aspect_ratio(true) .maintain_aspect_ratio(true)
.max_height(15.0), .max_height(15.0),
); );
} }
}
if i == focused_window_idx {
let font_id = ctx
.style()
.text_styles
.get(&TextStyle::Body)
.cloned()
.unwrap_or_else(FontId::default);
let layout_job = LayoutJob::simple(
title.to_string(),
font_id.clone(),
komorebi_notification_state
.stack_accent
.unwrap_or(ctx.style().visuals.selection.stroke.color),
100.0,
);
if titles.len() > 1 {
let available_height = ui.available_height();
let mut custom_ui = CustomUi(ui);
custom_ui.add_sized_left_to_right(
Vec2::new(
MAX_LABEL_WIDTH.load(Ordering::SeqCst) as f32,
available_height,
),
Label::new(layout_job).selectable(false).truncate(),
);
} else {
let available_height = ui.available_height();
let mut custom_ui = CustomUi(ui);
custom_ui.add_sized_left_to_right(
Vec2::new(
MAX_LABEL_WIDTH.load(Ordering::SeqCst) as f32,
available_height,
),
Label::new(title).selectable(false).truncate(),
);
}
} else {
let available_height = ui.available_height();
let mut custom_ui = CustomUi(ui);
if custom_ui
.add_sized_left_to_right(
Vec2::new(
MAX_LABEL_WIDTH.load(Ordering::SeqCst) as f32,
available_height,
),
Label::new(title)
.selectable(false)
.sense(Sense::click())
.truncate(),
)
.clicked()
{
if komorebi_client::send_message(&SocketMessage::MouseFollowsFocus(
false,
))
.is_err()
{
tracing::error!(
"could not send message to komorebi: MouseFollowsFocus"
);
} }
if komorebi_client::send_message(&SocketMessage::FocusStackWindow(i)) if i == focused_window_idx {
.is_err() let font_id = ctx
{ .style()
tracing::error!( .text_styles
"could not send message to komorebi: FocusStackWindow" .get(&TextStyle::Body)
); .cloned()
} .unwrap_or_else(FontId::default);
if komorebi_client::send_message(&SocketMessage::MouseFollowsFocus( let layout_job = LayoutJob::simple(
komorebi_notification_state.mouse_follows_focus, title.to_string(),
)) font_id.clone(),
.is_err() komorebi_notification_state
{ .stack_accent
tracing::error!( .unwrap_or(ctx.style().visuals.selection.stroke.color),
"could not send message to komorebi: MouseFollowsFocus" 100.0,
); );
if titles.len() > 1 {
let available_height = ui.available_height();
let mut custom_ui = CustomUi(ui);
custom_ui.add_sized_left_to_right(
Vec2::new(
MAX_LABEL_WIDTH.load(Ordering::SeqCst) as f32,
available_height,
),
Label::new(layout_job).selectable(false).truncate(),
);
} else {
let available_height = ui.available_height();
let mut custom_ui = CustomUi(ui);
custom_ui.add_sized_left_to_right(
Vec2::new(
MAX_LABEL_WIDTH.load(Ordering::SeqCst) as f32,
available_height,
),
Label::new(title).selectable(false).truncate(),
);
}
} else {
let available_height = ui.available_height();
let mut custom_ui = CustomUi(ui);
if custom_ui
.add_sized_left_to_right(
Vec2::new(
MAX_LABEL_WIDTH.load(Ordering::SeqCst) as f32,
available_height,
),
Label::new(title)
.selectable(false)
.sense(Sense::click())
.truncate(),
)
.clicked()
{
if komorebi_client::send_message(
&SocketMessage::MouseFollowsFocus(false),
)
.is_err()
{
tracing::error!(
"could not send message to komorebi: MouseFollowsFocus"
);
}
if komorebi_client::send_message(
&SocketMessage::FocusStackWindow(i),
)
.is_err()
{
tracing::error!(
"could not send message to komorebi: FocusStackWindow"
);
}
if komorebi_client::send_message(
&SocketMessage::MouseFollowsFocus(
komorebi_notification_state.mouse_follows_focus,
),
)
.is_err()
{
tracing::error!(
"could not send message to komorebi: MouseFollowsFocus"
);
}
}
} }
} }
} });
ui.add_space(WIDGET_SPACING);
} }
} }
ui.add_space(WIDGET_SPACING);
} }
} }
} }

View File

@@ -7,6 +7,7 @@ mod komorebi;
mod media; mod media;
mod memory; mod memory;
mod network; mod network;
mod render;
mod storage; mod storage;
mod time; mod time;
mod ui; mod ui;
@@ -42,8 +43,6 @@ use windows::Win32::UI::HiDpi::DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2;
use windows::Win32::UI::WindowsAndMessaging::EnumThreadWindows; use windows::Win32::UI::WindowsAndMessaging::EnumThreadWindows;
use windows::Win32::UI::WindowsAndMessaging::GetWindowThreadProcessId; use windows::Win32::UI::WindowsAndMessaging::GetWindowThreadProcessId;
pub static WIDGET_SPACING: f32 = 10.0;
pub static MAX_LABEL_WIDTH: AtomicI32 = AtomicI32::new(400); pub static MAX_LABEL_WIDTH: AtomicI32 = AtomicI32::new(400);
pub static MONITOR_LEFT: AtomicI32 = AtomicI32::new(0); pub static MONITOR_LEFT: AtomicI32 = AtomicI32::new(0);
pub static MONITOR_TOP: AtomicI32 = AtomicI32::new(0); pub static MONITOR_TOP: AtomicI32 = AtomicI32::new(0);
@@ -266,7 +265,7 @@ fn main() -> color_eyre::Result<()> {
let viewport_builder = ViewportBuilder::default() let viewport_builder = ViewportBuilder::default()
.with_decorations(false) .with_decorations(false)
// .with_transparent(config.transparent) .with_transparent(config.transparency_alpha.is_some())
.with_taskbar(false); .with_taskbar(false);
let native_options = eframe::NativeOptions { let native_options = eframe::NativeOptions {

View File

@@ -1,7 +1,7 @@
use crate::render::RenderConfig;
use crate::ui::CustomUi; use crate::ui::CustomUi;
use crate::widget::BarWidget; use crate::widget::BarWidget;
use crate::MAX_LABEL_WIDTH; use crate::MAX_LABEL_WIDTH;
use crate::WIDGET_SPACING;
use eframe::egui::text::LayoutJob; use eframe::egui::text::LayoutJob;
use eframe::egui::Context; use eframe::egui::Context;
use eframe::egui::FontId; use eframe::egui::FontId;
@@ -78,7 +78,7 @@ impl Media {
} }
impl BarWidget for Media { impl BarWidget for Media {
fn render(&mut self, ctx: &Context, ui: &mut Ui) { fn render(&mut self, ctx: &Context, ui: &mut Ui, config: &mut RenderConfig) {
if self.enable { if self.enable {
let output = self.output(); let output = self.output();
if !output.is_empty() { if !output.is_empty() {
@@ -102,26 +102,26 @@ impl BarWidget for Media {
TextFormat::simple(font_id, ctx.style().visuals.text_color()), TextFormat::simple(font_id, ctx.style().visuals.text_color()),
); );
let available_height = ui.available_height(); config.apply_on_widget(true, ui, |ui| {
let mut custom_ui = CustomUi(ui); let available_height = ui.available_height();
let mut custom_ui = CustomUi(ui);
if custom_ui if custom_ui
.add_sized_left_to_right( .add_sized_left_to_right(
Vec2::new( Vec2::new(
MAX_LABEL_WIDTH.load(Ordering::SeqCst) as f32, MAX_LABEL_WIDTH.load(Ordering::SeqCst) as f32,
available_height, available_height,
), ),
Label::new(layout_job) Label::new(layout_job)
.selectable(false) .selectable(false)
.sense(Sense::click()) .sense(Sense::click())
.truncate(), .truncate(),
) )
.clicked() .clicked()
{ {
self.toggle(); self.toggle();
} }
});
ui.add_space(WIDGET_SPACING);
} }
} }
} }

View File

@@ -1,6 +1,6 @@
use crate::config::LabelPrefix; use crate::config::LabelPrefix;
use crate::render::RenderConfig;
use crate::widget::BarWidget; use crate::widget::BarWidget;
use crate::WIDGET_SPACING;
use eframe::egui::text::LayoutJob; use eframe::egui::text::LayoutJob;
use eframe::egui::Context; use eframe::egui::Context;
use eframe::egui::FontId; use eframe::egui::FontId;
@@ -73,7 +73,7 @@ impl Memory {
} }
impl BarWidget for Memory { impl BarWidget for Memory {
fn render(&mut self, ctx: &Context, ui: &mut Ui) { fn render(&mut self, ctx: &Context, ui: &mut Ui, config: &mut RenderConfig) {
if self.enable { if self.enable {
let output = self.output(); let output = self.output();
if !output.is_empty() { if !output.is_empty() {
@@ -102,22 +102,23 @@ impl BarWidget for Memory {
TextFormat::simple(font_id, ctx.style().visuals.text_color()), TextFormat::simple(font_id, ctx.style().visuals.text_color()),
); );
if ui config.apply_on_widget(true, ui, |ui| {
.add( if ui
Label::new(layout_job) .add(
.selectable(false) Label::new(layout_job)
.sense(Sense::click()), .selectable(false)
) .sense(Sense::click()),
.clicked() )
{ .clicked()
if let Err(error) = Command::new("cmd.exe").args(["/C", "taskmgr.exe"]).spawn()
{ {
eprintln!("{}", error) if let Err(error) =
Command::new("cmd.exe").args(["/C", "taskmgr.exe"]).spawn()
{
eprintln!("{}", error)
}
} }
} });
} }
ui.add_space(WIDGET_SPACING);
} }
} }
} }

View File

@@ -1,6 +1,6 @@
use crate::config::LabelPrefix; use crate::config::LabelPrefix;
use crate::render::RenderConfig;
use crate::widget::BarWidget; use crate::widget::BarWidget;
use crate::WIDGET_SPACING;
use eframe::egui::text::LayoutJob; use eframe::egui::text::LayoutJob;
use eframe::egui::Context; use eframe::egui::Context;
use eframe::egui::FontId; use eframe::egui::FontId;
@@ -317,21 +317,21 @@ impl Network {
} }
impl BarWidget for Network { impl BarWidget for Network {
fn render(&mut self, ctx: &Context, ui: &mut Ui) { fn render(&mut self, ctx: &Context, ui: &mut Ui, config: &mut RenderConfig) {
if self.show_total_data_transmitted { if self.show_total_data_transmitted {
for output in self.total_data_transmitted() { for output in self.total_data_transmitted() {
ui.add(Label::new(output).selectable(false)); config.apply_on_widget(true, ui, |ui| {
ui.add(Label::new(output).selectable(false));
});
} }
ui.add_space(WIDGET_SPACING);
} }
if self.show_network_activity { if self.show_network_activity {
for output in self.network_activity() { for output in self.network_activity() {
ui.add(Label::new(output).selectable(false)); config.apply_on_widget(true, ui, |ui| {
ui.add(Label::new(output).selectable(false));
});
} }
ui.add_space(WIDGET_SPACING);
} }
if self.enable { if self.enable {
@@ -367,21 +367,21 @@ impl BarWidget for Network {
TextFormat::simple(font_id, ctx.style().visuals.text_color()), TextFormat::simple(font_id, ctx.style().visuals.text_color()),
); );
if ui config.apply_on_widget(true, ui, |ui| {
.add( if ui
Label::new(layout_job) .add(
.selectable(false) Label::new(layout_job)
.sense(Sense::click()), .selectable(false)
) .sense(Sense::click()),
.clicked() )
{ .clicked()
if let Err(error) = Command::new("cmd.exe").args(["/C", "ncpa"]).spawn() { {
eprintln!("{}", error) if let Err(error) = Command::new("cmd.exe").args(["/C", "ncpa"]).spawn() {
eprintln!("{}", error)
}
} }
} });
} }
ui.add_space(WIDGET_SPACING);
} }
} }
} }

263
komorebi-bar/src/render.rs Normal file
View File

@@ -0,0 +1,263 @@
use crate::bar::Alignment;
use crate::config::KomobarConfig;
use eframe::egui::Color32;
use eframe::egui::Frame;
use eframe::egui::InnerResponse;
use eframe::egui::Margin;
use eframe::egui::Rounding;
use eframe::egui::Shadow;
use eframe::egui::Ui;
use eframe::egui::Vec2;
use schemars::JsonSchema;
use serde::Deserialize;
use serde::Serialize;
#[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema)]
#[serde(tag = "kind")]
pub enum Grouping {
/// No grouping is applied
None,
/// Widgets are grouped as a whole
Bar(GroupingConfig),
/// Widgets are grouped by alignment
Alignment(GroupingConfig),
/// Widgets are grouped individually
Widget(GroupingConfig),
}
#[derive(Copy, Clone)]
pub struct RenderConfig {
/// Spacing between widgets
pub spacing: f32,
/// Sets how widgets are grouped
pub grouping: Grouping,
/// Background color
pub background_color: Color32,
/// Alignment of the widgets
pub alignment: Option<Alignment>,
/// Add more inner margin when adding a widget group
pub more_inner_margin: bool,
/// Set to true after the first time the apply_on_widget was called on an alignment
pub applied_on_widget: bool,
}
pub trait RenderExt {
fn new_renderconfig(&self, background_color: Color32) -> RenderConfig;
}
impl RenderExt for &KomobarConfig {
fn new_renderconfig(&self, background_color: Color32) -> RenderConfig {
RenderConfig {
spacing: self.widget_spacing.unwrap_or(10.0),
grouping: self.grouping.unwrap_or(Grouping::None),
background_color,
alignment: None,
more_inner_margin: false,
applied_on_widget: false,
}
}
}
impl RenderConfig {
pub fn new() -> Self {
Self {
spacing: 0.0,
grouping: Grouping::None,
background_color: Color32::BLACK,
alignment: None,
more_inner_margin: false,
applied_on_widget: false,
}
}
pub fn apply_on_bar<R>(
&mut self,
ui: &mut Ui,
add_contents: impl FnOnce(&mut Ui) -> R,
) -> InnerResponse<R> {
self.alignment = None;
if let Grouping::Bar(config) = self.grouping {
return self.define_group(None, config, ui, add_contents);
}
Self::fallback_group(ui, add_contents)
}
pub fn apply_on_alignment<R>(
&mut self,
ui: &mut Ui,
add_contents: impl FnOnce(&mut Ui) -> R,
) -> InnerResponse<R> {
self.alignment = None;
if let Grouping::Alignment(config) = self.grouping {
return self.define_group(None, config, ui, add_contents);
}
Self::fallback_group(ui, add_contents)
}
pub fn apply_on_widget<R>(
&mut self,
more_inner_margin: bool,
ui: &mut Ui,
add_contents: impl FnOnce(&mut Ui) -> R,
) -> InnerResponse<R> {
self.more_inner_margin = more_inner_margin;
let outer_margin = self.widget_outer_margin(ui);
if let Grouping::Widget(config) = self.grouping {
return self.define_group(Some(outer_margin), config, ui, add_contents);
}
self.fallback_widget_group(Some(outer_margin), ui, add_contents)
}
fn fallback_group<R>(ui: &mut Ui, add_contents: impl FnOnce(&mut Ui) -> R) -> InnerResponse<R> {
InnerResponse {
inner: add_contents(ui),
response: ui.response().clone(),
}
}
fn fallback_widget_group<R>(
&mut self,
outer_margin: Option<Margin>,
ui: &mut Ui,
add_contents: impl FnOnce(&mut Ui) -> R,
) -> InnerResponse<R> {
Frame::none()
.outer_margin(outer_margin.unwrap_or(Margin::ZERO))
.inner_margin(match self.more_inner_margin {
true => Margin::symmetric(5.0, 0.0),
false => Margin::same(0.0),
})
.show(ui, add_contents)
}
fn define_group<R>(
&mut self,
outer_margin: Option<Margin>,
config: GroupingConfig,
ui: &mut Ui,
add_contents: impl FnOnce(&mut Ui) -> R,
) -> InnerResponse<R> {
Frame::group(ui.style_mut())
.outer_margin(outer_margin.unwrap_or(Margin::ZERO))
.inner_margin(match self.more_inner_margin {
true => Margin::symmetric(8.0, 3.0),
false => Margin::symmetric(3.0, 3.0),
})
.stroke(ui.style().visuals.widgets.noninteractive.bg_stroke)
.rounding(match config.rounding {
Some(rounding) => rounding.into(),
None => ui.style().visuals.widgets.noninteractive.rounding,
})
.fill(
self.background_color
.try_apply_alpha(config.transparency_alpha),
)
.shadow(match config.style {
Some(style) => match style {
// new styles can be added if needed here
GroupingStyle::Default => Shadow::NONE,
GroupingStyle::DefaultWithShadow => Shadow {
blur: 4.0,
offset: Vec2::new(1.0, 1.0),
spread: 3.0,
color: Color32::BLACK.try_apply_alpha(config.transparency_alpha),
},
},
None => Shadow::NONE,
})
.show(ui, add_contents)
}
fn widget_outer_margin(&mut self, ui: &mut Ui) -> Margin {
let spacing = if self.applied_on_widget {
// Remove the default item spacing from the margin
self.spacing - ui.spacing().item_spacing.x
} else {
0.0
};
if !self.applied_on_widget {
self.applied_on_widget = true;
}
Margin {
left: match self.alignment {
Some(align) => match align {
Alignment::Left => spacing,
Alignment::Right => 0.0,
},
None => 0.0,
},
right: match self.alignment {
Some(align) => match align {
Alignment::Left => 0.0,
Alignment::Right => spacing,
},
None => 0.0,
},
top: 0.0,
bottom: 0.0,
}
}
}
#[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema)]
pub struct GroupingConfig {
/// Styles for the grouping
pub style: Option<GroupingStyle>,
/// Alpha value for the color transparency [[0-255]] (default: 200)
pub transparency_alpha: Option<u8>,
/// Rounding values for the 4 corners. Can be a single or 4 values.
pub rounding: Option<RoundingConfig>,
}
#[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema)]
pub enum GroupingStyle {
Default,
/// A black shadow is added under the default group
DefaultWithShadow,
}
#[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema)]
#[serde(untagged)]
pub enum RoundingConfig {
/// All 4 corners are the same
Same(f32),
/// All 4 corners are custom. Order: NW, NE, SW, SE
Individual([f32; 4]),
}
impl From<RoundingConfig> for Rounding {
fn from(value: RoundingConfig) -> Self {
match value {
RoundingConfig::Same(value) => Rounding::same(value),
RoundingConfig::Individual(values) => Self {
nw: values[0],
ne: values[1],
sw: values[2],
se: values[3],
},
}
}
}
pub trait Color32Ext {
fn try_apply_alpha(self, transparency_alpha: Option<u8>) -> Self;
}
impl Color32Ext for Color32 {
/// Tries to apply the alpha value to the Color32
fn try_apply_alpha(self, transparency_alpha: Option<u8>) -> Self {
if let Some(alpha) = transparency_alpha {
return Color32::from_rgba_unmultiplied(self.r(), self.g(), self.b(), alpha);
}
self
}
}

View File

@@ -1,6 +1,6 @@
use crate::config::LabelPrefix; use crate::config::LabelPrefix;
use crate::render::RenderConfig;
use crate::widget::BarWidget; use crate::widget::BarWidget;
use crate::WIDGET_SPACING;
use eframe::egui::text::LayoutJob; use eframe::egui::text::LayoutJob;
use eframe::egui::Context; use eframe::egui::Context;
use eframe::egui::FontId; use eframe::egui::FontId;
@@ -79,7 +79,7 @@ impl Storage {
} }
impl BarWidget for Storage { impl BarWidget for Storage {
fn render(&mut self, ctx: &Context, ui: &mut Ui) { fn render(&mut self, ctx: &Context, ui: &mut Ui, config: &mut RenderConfig) {
if self.enable { if self.enable {
let font_id = ctx let font_id = ctx
.style() .style()
@@ -107,27 +107,27 @@ impl BarWidget for Storage {
TextFormat::simple(font_id.clone(), ctx.style().visuals.text_color()), TextFormat::simple(font_id.clone(), ctx.style().visuals.text_color()),
); );
if ui config.apply_on_widget(true, ui, |ui| {
.add( if ui
Label::new(layout_job) .add(
.selectable(false) Label::new(layout_job)
.sense(Sense::click()), .selectable(false)
) .sense(Sense::click()),
.clicked() )
{ .clicked()
if let Err(error) = Command::new("cmd.exe")
.args([
"/C",
"explorer.exe",
output.split(' ').collect::<Vec<&str>>()[0],
])
.spawn()
{ {
eprintln!("{}", error) if let Err(error) = Command::new("cmd.exe")
.args([
"/C",
"explorer.exe",
output.split(' ').collect::<Vec<&str>>()[0],
])
.spawn()
{
eprintln!("{}", error)
}
} }
} });
ui.add_space(WIDGET_SPACING);
} }
} }
} }

View File

@@ -1,6 +1,6 @@
use crate::config::LabelPrefix; use crate::config::LabelPrefix;
use crate::render::RenderConfig;
use crate::widget::BarWidget; use crate::widget::BarWidget;
use crate::WIDGET_SPACING;
use eframe::egui::text::LayoutJob; use eframe::egui::text::LayoutJob;
use eframe::egui::Context; use eframe::egui::Context;
use eframe::egui::FontId; use eframe::egui::FontId;
@@ -77,7 +77,7 @@ impl Time {
} }
impl BarWidget for Time { impl BarWidget for Time {
fn render(&mut self, ctx: &Context, ui: &mut Ui) { fn render(&mut self, ctx: &Context, ui: &mut Ui, config: &mut RenderConfig) {
if self.enable { if self.enable {
let mut output = self.output(); let mut output = self.output();
if !output.is_empty() { if !output.is_empty() {
@@ -110,19 +110,19 @@ impl BarWidget for Time {
TextFormat::simple(font_id, ctx.style().visuals.text_color()), TextFormat::simple(font_id, ctx.style().visuals.text_color()),
); );
if ui config.apply_on_widget(true, ui, |ui| {
.add( if ui
Label::new(layout_job) .add(
.selectable(false) Label::new(layout_job)
.sense(Sense::click()), .selectable(false)
) .sense(Sense::click()),
.clicked() )
{ .clicked()
self.format.toggle() {
} self.format.toggle()
}
});
} }
ui.add_space(WIDGET_SPACING);
} }
} }
} }

View File

@@ -12,6 +12,7 @@ use crate::memory::Memory;
use crate::memory::MemoryConfig; use crate::memory::MemoryConfig;
use crate::network::Network; use crate::network::Network;
use crate::network::NetworkConfig; use crate::network::NetworkConfig;
use crate::render::RenderConfig;
use crate::storage::Storage; use crate::storage::Storage;
use crate::storage::StorageConfig; use crate::storage::StorageConfig;
use crate::time::Time; use crate::time::Time;
@@ -23,7 +24,7 @@ use serde::Deserialize;
use serde::Serialize; use serde::Serialize;
pub trait BarWidget { pub trait BarWidget {
fn render(&mut self, ctx: &Context, ui: &mut Ui); fn render(&mut self, ctx: &Context, ui: &mut Ui, config: &mut RenderConfig);
} }
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]

View File

@@ -39,6 +39,18 @@ impl From<Color32> for Colour {
} }
} }
impl From<Colour> for Color32 {
fn from(value: Colour) -> Self {
match value {
Colour::Rgb(rgb) => Color32::from_rgb(rgb.r as u8, rgb.g as u8, rgb.b as u8),
Colour::Hex(hex) => {
let rgb = Rgb::from(hex);
Color32::from_rgb(rgb.r as u8, rgb.g as u8, rgb.b as u8)
}
}
}
}
#[derive(Debug, Copy, Clone, Serialize, Deserialize)] #[derive(Debug, Copy, Clone, Serialize, Deserialize)]
pub struct Hex(HexColor); pub struct Hex(HexColor);