use std::ops::Range;
use eframe::egui;
use crate::icon::{icon, Glyph};
pub const HEADER: f32 = 24.0;
pub const DOCK: f32 = crate::tabs::HEIGHT;
pub(crate) const PAD: f32 = 8.0;
pub(crate) const GAP: f32 = 6.0;
pub(crate) const GLYPH: f32 = 13.0;
const CHEVRON: f32 = 12.0;
const GRIP: f32 = 12.0;
const GRIP_ALPHA: f32 = 0.6;
pub enum Track {
Px(f32),
Share(f32),
Capped { share: f32, max: f32 },
}
impl Track {
fn px(&self) -> f32 {
match self {
Track::Px(px) => *px,
Track::Share(_) | Track::Capped { .. } => 0.0,
}
}
fn share(&self) -> f32 {
match self {
Track::Px(_) => 0.0,
Track::Share(share) | Track::Capped { share, .. } => *share,
}
}
}
fn rate(spare: f32, wanted: &[Track]) -> f32 {
let mut caps: Vec<(f32, f32)> = wanted
.iter()
.filter_map(|track| match track {
Track::Capped { share, max } => Some((*share, *max)),
Track::Px(_) | Track::Share(_) => None,
})
.collect();
caps.sort_by(|(share, max), (other, limit)| (max / share).total_cmp(&(limit / other)));
let mut spare = spare;
let mut pool: f32 = wanted.iter().map(Track::share).sum();
for (share, max) in caps {
if pool <= 0.0 || spare / pool * share <= max {
break;
}
spare -= max;
pool -= share;
}
match pool > 0.0 {
true => spare / pool,
false => 0.0,
}
}
pub fn tracks(width: f32, wanted: &[Track], gap: f32) -> Vec<Range<f32>> {
let gaps = gap * (wanted.len().saturating_sub(1)) as f32;
let fixed: f32 = wanted.iter().map(Track::px).sum();
let spare = (width - gaps - fixed).max(0.0);
let rate = rate(spare, wanted);
let asked: Vec<f32> = wanted
.iter()
.map(|track| match track {
Track::Px(px) => *px,
Track::Share(share) => rate * share,
Track::Capped { share, max } => (rate * share).min(*max),
})
.collect();
let total: f32 = asked.iter().sum::<f32>() + gaps;
let scale = match total > width {
true => (width / total).max(0.0),
false => 1.0,
};
let mut x = 0.0;
asked
.iter()
.map(|held| {
let track = x..x + held * scale;
x = track.end + gap * scale;
track
})
.collect()
}
pub fn chip(
ui: &mut egui::Ui,
glyph: Glyph,
size: f32,
text: &str,
tint: egui::Color32,
fill: Option<egui::Color32>,
) -> egui::Response {
let border = ui.visuals().widgets.noninteractive.bg_stroke.color;
egui::Frame::new()
.fill(fill.unwrap_or(egui::Color32::TRANSPARENT))
.stroke(egui::Stroke::new(1.0_f32, border))
.corner_radius(2.0)
.inner_margin(egui::Margin::symmetric(5, 1))
.show(ui, |ui| {
ui.spacing_mut().item_spacing.x = 4.0;
icon(ui, glyph, size, tint);
ui.label(
egui::RichText::new(text)
.text_style(crate::app::ui())
.color(tint),
);
})
.response
}
pub fn caps(text: &str) -> egui::RichText {
egui::RichText::new(text.to_uppercase()).text_style(crate::app::micro())
}
pub fn panel_header(
ui: &mut egui::Ui,
title: &str,
open: Option<&mut bool>,
badge: Option<(&str, egui::Color32)>,
) -> egui::Response {
bar(ui, HEADER, egui::Color32::TRANSPARENT, |ui| {
if let Some(open) = open {
if chevron(ui, *open).clicked() {
*open = !*open;
}
}
ui.label(caps(title).color(crate::app::caption(ui.visuals())));
let Some((badge, tint)) = badge else {
return;
};
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.label(
egui::RichText::new(badge)
.text_style(crate::app::micro())
.color(tint),
);
});
})
}
pub fn dock_header(ui: &mut egui::Ui, title: &str) -> egui::Response {
let fill = ui.visuals().faint_bg_color;
let response = bar(ui, DOCK, fill, |ui| {
let ink = crate::app::caption(ui.visuals());
icon(
ui,
Glyph::GripVertical,
GRIP,
ink.gamma_multiply(GRIP_ALPHA),
);
ui.label(caps(title).color(ink));
});
let stroke = egui::Stroke::new(1.0_f32, ui.visuals().widgets.noninteractive.bg_stroke.color);
let rect = response.rect;
ui.painter()
.hline(rect.x_range(), rect.bottom() - 0.5, stroke);
response
}
pub fn strip<R>(ui: &mut egui::Ui, contents: impl FnOnce(&mut egui::Ui) -> R) -> egui::Response {
let fill = ui.visuals().faint_bg_color;
bar(ui, DOCK, fill, contents)
}
fn bar<R>(
ui: &mut egui::Ui,
height: f32,
resting: egui::Color32,
contents: impl FnOnce(&mut egui::Ui) -> R,
) -> egui::Response {
let (rect, response) = ui.allocate_exact_size(
egui::vec2(ui.available_width(), height),
egui::Sense::click(),
);
let fill = match response.hovered() {
true => ui.visuals().faint_bg_color,
false => resting,
};
ui.painter().rect_filled(rect, 0.0, fill);
let mut inner = ui.new_child(
egui::UiBuilder::new()
.max_rect(rect.shrink2(egui::vec2(PAD, 0.0)))
.layout(egui::Layout::left_to_right(egui::Align::Center)),
);
inner.spacing_mut().item_spacing.x = GAP;
contents(&mut inner);
response
}
pub fn chevron(ui: &mut egui::Ui, open: bool) -> egui::Response {
let glyph = match open {
true => Glyph::ChevronDown,
false => Glyph::ChevronRight,
};
let drawn = icon(ui, glyph, CHEVRON, crate::app::caption(ui.visuals()));
ui.interact(drawn.rect, drawn.id.with("chevron"), egui::Sense::click())
}
pub fn dashed_rect(painter: &egui::Painter, rect: egui::Rect, stroke: egui::Stroke) {
const DASH: f32 = 3.0;
let corners = [
rect.left_top(),
rect.right_top(),
rect.right_bottom(),
rect.left_bottom(),
rect.left_top(),
];
for side in corners.windows(2) {
painter.extend(egui::Shape::dashed_line(side, stroke, DASH, DASH));
}
}
pub fn flat(ui: &mut egui::Ui) {
let widgets = &mut ui.visuals_mut().widgets;
widgets.inactive.weak_bg_fill = egui::Color32::TRANSPARENT;
widgets.inactive.bg_fill = egui::Color32::TRANSPARENT;
for state in [
&mut widgets.inactive,
&mut widgets.hovered,
&mut widgets.active,
&mut widgets.open,
] {
state.bg_stroke = egui::Stroke::NONE;
}
}
#[cfg(test)]
mod tests {
use super::*;
fn widths(width: f32, wanted: &[Track]) -> Vec<f32> {
tracks(width, wanted, 0.0)
.iter()
.map(|track| track.end - track.start)
.collect()
}
#[test]
fn a_capped_track_stops_at_its_maximum_and_hands_the_rest_to_the_other_shares() {
let wanted = [
Track::Capped {
share: 1.0,
max: 40.0,
},
Track::Share(1.0),
];
assert_eq!(widths(60.0, &wanted), vec![30.0, 30.0]);
assert_eq!(widths(100.0, &wanted), vec![40.0, 60.0]);
assert_eq!(widths(1000.0, &wanted), vec![40.0, 960.0]);
}
#[test]
fn a_width_below_the_fixed_tracks_shrinks_a_capped_track_like_any_other() {
let wanted = [
Track::Px(50.0),
Track::Capped {
share: 1.0,
max: 40.0,
},
Track::Share(1.0),
];
for width in [0.0_f32, 10.0, 50.0] {
let held = widths(width, &wanted);
assert!(
held.iter().all(|track| *track >= 0.0),
"at {width}: {held:?}"
);
assert!(
held.iter().sum::<f32>() <= width + 0.01,
"at {width}: {held:?}"
);
}
}
#[test]
fn a_title_is_uppercased_whatever_it_arrives_as() {
assert_eq!(caps("send queue").text(), "SEND QUEUE");
assert_eq!(caps("Browser").text(), "BROWSER");
}
#[test]
fn a_header_claims_its_own_height_and_the_whole_width() {
let ctx = egui::Context::default();
let mut section = egui::Rect::ZERO;
let mut dock = egui::Rect::ZERO;
let mut width = 0.0;
let _ = ctx.run(egui::RawInput::default(), |ctx| {
ctx.style_mut(crate::app::metrics);
egui::CentralPanel::default().show(ctx, |ui| {
width = ui.available_width();
section = panel_header(ui, "places", None, None).rect;
dock = dock_header(ui, "browser").rect;
});
});
assert_eq!(section.height(), HEADER);
assert_eq!(dock.height(), crate::tabs::HEIGHT);
assert_eq!(section.width(), width);
assert_eq!(dock.width(), width);
}
fn fills(output: &egui::FullOutput, rect: egui::Rect) -> Vec<egui::Color32> {
fn walk(shape: &egui::Shape, rect: egui::Rect, into: &mut Vec<egui::Color32>) {
match shape {
egui::Shape::Rect(drawn) if drawn.rect == rect => into.push(drawn.fill),
egui::Shape::Vec(shapes) => shapes.iter().for_each(|shape| walk(shape, rect, into)),
_ => {}
}
}
let mut found = Vec::new();
for clipped in &output.shapes {
walk(&clipped.shape, rect, &mut found);
}
found
}
fn header_fill(
ctx: &egui::Context,
under_pointer: bool,
header: impl Fn(&mut egui::Ui) -> egui::Response,
) -> Vec<egui::Color32> {
let at = std::cell::Cell::new(egui::Pos2::ZERO);
let mut fill = Vec::new();
for _ in 0..2 {
let input = egui::RawInput {
events: match under_pointer {
true => vec![egui::Event::PointerMoved(at.get())],
false => Vec::new(),
},
..Default::default()
};
let mut rect = egui::Rect::NOTHING;
let output = ctx.run(input, |ctx| {
ctx.style_mut(crate::app::metrics);
egui::CentralPanel::default().show(ctx, |ui| rect = header(ui).rect);
});
at.set(rect.center());
fill = fills(&output, rect);
}
fill
}
#[test]
fn a_section_header_wears_the_panel_until_the_pointer_is_on_it() {
let ctx = egui::Context::default();
let section = |ui: &mut egui::Ui| panel_header(ui, "places", None, None);
assert_eq!(
header_fill(&ctx, false, section),
vec![egui::Color32::TRANSPARENT],
);
assert_eq!(
header_fill(&ctx, true, section),
vec![ctx.style().visuals.faint_bg_color],
);
}
#[test]
fn a_dock_header_keeps_its_own_colour_whether_or_not_it_is_pointed_at() {
let ctx = egui::Context::default();
let faint = ctx.style().visuals.faint_bg_color;
for pointed in [false, true] {
assert_eq!(
header_fill(&ctx, pointed, |ui| dock_header(ui, "browser")),
vec![faint],
"pointed at: {pointed}",
);
}
}
#[test]
fn the_triangle_toggles_the_bool_it_was_handed() {
let ctx = egui::Context::default();
let mut open = true;
let at = std::cell::Cell::new(egui::Pos2::ZERO);
let frame = |press: bool, open: &mut bool| {
let input = egui::RawInput {
events: match press {
true => vec![
egui::Event::PointerMoved(at.get()),
egui::Event::PointerButton {
pos: at.get(),
button: egui::PointerButton::Primary,
pressed: true,
modifiers: egui::Modifiers::NONE,
},
egui::Event::PointerButton {
pos: at.get(),
button: egui::PointerButton::Primary,
pressed: false,
modifiers: egui::Modifiers::NONE,
},
],
false => Vec::new(),
},
..Default::default()
};
let _ = ctx.run(input, |ctx| {
ctx.style_mut(crate::app::metrics);
egui::CentralPanel::default().show(ctx, |ui| {
let rect = panel_header(ui, "browser", Some(open), None).rect;
at.set(egui::pos2(
rect.left() + PAD + CHEVRON / 2.0,
rect.center().y,
));
});
});
};
frame(false, &mut open);
frame(true, &mut open);
assert!(!open, "a click on the triangle shuts the dock");
frame(true, &mut open);
assert!(open, "and the next one opens it again");
}
}