use eframe::egui::{self, Align, Layout, RichText, Sense};
use egui_extras::{Column, TableBuilder, TableRow};
use super::icons::{self, Icon};
use crate::theme::Palette;
const ROW_HEIGHT: f32 = 48.0;
const NAME_MIN: f32 = 180.0;
const COMPACT_ROW_HEIGHT: f32 = 30.0;
pub fn set_compact(ctx: &egui::Context, compact: bool) {
ctx.data_mut(|d| d.insert_temp(compact_id(), compact));
}
pub fn is_compact(ui: &egui::Ui) -> bool {
ui.data(|d| d.get_temp(compact_id()).unwrap_or(false))
}
fn compact_id() -> egui::Id {
egui::Id::unique("ferrodock-compact")
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Ticked {
None,
Some,
All,
}
impl Ticked {
pub fn of(ticked: usize, shown: usize) -> Self {
match ticked {
0 => Ticked::None,
n if n >= shown => Ticked::All,
_ => Ticked::Some,
}
}
}
pub struct Col {
pub title: &'static str,
pub width: Column,
pub ticked: Option<Ticked>,
}
impl Col {
pub fn ticks(ticked: Ticked) -> Self {
Self {
title: "",
width: Column::exact(28.0),
ticked: Some(ticked),
}
}
pub fn fill(title: &'static str) -> Self {
Self {
title,
width: Column::remainder().at_least(160.0).clip(true),
ticked: None,
}
}
pub fn fixed(title: &'static str, width: f32) -> Self {
Self {
title,
width: Column::exact(width).clip(true),
ticked: None,
}
}
}
pub fn fitting(ui: &egui::Ui, required: &[f32], optional: &[f32]) -> Vec<bool> {
let gap = ui.spacing().item_spacing.x;
let scroll = &ui.spacing().scroll;
let taken: f32 = required.iter().map(|w| w + gap).sum();
let mut room = ui.available_width()
- taken
- (NAME_MIN + gap)
- (scroll.bar_width + scroll.bar_outer_margin);
optional
.iter()
.map(|&width| {
let fits = width + gap <= room;
if fits {
room -= width + gap;
}
fits
})
.collect()
}
pub fn request_step(ctx: &egui::Context, delta: i32) {
ctx.data_mut(|d| d.insert_temp(step_id(), delta));
}
pub fn clear_step(ctx: &egui::Context) {
ctx.data_mut(|d| d.remove_temp::<i32>(step_id()));
}
fn step_id() -> egui::Id {
egui::Id::unique("ferrodock-table-step")
}
fn select_all_id(table: &str) -> egui::Id {
egui::Id::unique("ferrodock-table-select-all").with(table)
}
pub fn select_all_clicked(ui: &egui::Ui, id: &str) -> bool {
ui.data_mut(|d| d.remove_temp::<bool>(select_all_id(id)))
.unwrap_or(false)
}
pub fn tick(ui: &mut egui::Ui, p: &Palette, on: bool, name: &str) -> bool {
let state = if on { Ticked::All } else { Ticked::None };
tick_box(ui, p, state, &format!("Select {name}"))
}
pub fn tick_state(ui: &mut egui::Ui, p: &Palette, state: Ticked, label: &str) -> bool {
tick_box(ui, p, state, label)
}
fn tick_box(ui: &mut egui::Ui, p: &Palette, state: Ticked, label: &str) -> bool {
let (rect, response) = ui.allocate_exact_size(egui::vec2(16.0, 16.0), Sense::click());
let response = response.on_hover_cursor(egui::CursorIcon::PointingHand);
response.widget_info(|| {
egui::WidgetInfo::selected(egui::Role::CheckBox, true, state == Ticked::All, label)
});
let painter = ui.painter();
let corner = egui::CornerRadius::same(4);
if state == Ticked::None {
let stroke = if response.hovered() {
p.accent
} else {
p.muted.gamma_multiply(0.6)
};
painter.rect_stroke(
rect.shrink(0.5),
corner,
egui::Stroke::new(1.3, stroke),
egui::StrokeKind::Inside,
);
} else {
painter.rect_filled(rect, corner, p.accent);
let white = egui::Stroke::new(2.0, p.on_accent);
let c = rect.center();
if state == Ticked::All {
painter.line(
vec![
c + egui::vec2(-4.0, 0.0),
c + egui::vec2(-1.0, 3.0),
c + egui::vec2(4.5, -3.5),
],
white,
);
} else {
painter.line_segment([c + egui::vec2(-4.0, 0.0), c + egui::vec2(4.0, 0.0)], white);
}
}
response.clicked()
}
fn step(current: Option<usize>, delta: i32, rows: usize) -> Option<usize> {
if rows == 0 || delta == 0 {
return None;
}
let Some(current) = current else {
return Some(if delta > 0 { 0 } else { rows - 1 });
};
let next = (current as i64 + i64::from(delta)).clamp(0, rows as i64 - 1) as usize;
(next != current).then_some(next)
}
pub fn show(
ui: &mut egui::Ui,
p: &Palette,
id: &str,
columns: Vec<Col>,
rows: usize,
is_selected: impl Fn(usize) -> bool,
mut add_row: impl FnMut(usize, &mut TableRow<'_, '_>),
) -> Option<usize> {
let row_height = if is_compact(ui) {
COMPACT_ROW_HEIGHT
} else {
ROW_HEIGHT
};
let requested = ui.ctx().data_mut(|d| d.remove_temp::<i32>(step_id()));
let current = requested.and_then(|_| (0..rows).find(|&i| is_selected(i)));
let stepped = requested.and_then(|delta| step(current, delta, rows));
let mut clicked = stepped;
let mut builder = TableBuilder::new(ui)
.id_salt(id)
.sense(Sense::click())
.cell_layout(Layout::left_to_right(Align::Center));
if let Some(row) = stepped.or(current) {
builder = builder.scroll_to_row(row, None);
}
for col in &columns {
builder = builder.column(col.width);
}
builder
.header(26.0, |mut header| {
for col in &columns {
header.col(|ui| match col.ticked {
Some(state) => {
let label = if state == Ticked::All {
"Select none"
} else {
"Select all"
};
if tick_box(ui, p, state, label) {
ui.data_mut(|d| d.insert_temp(select_all_id(id), true));
}
}
None => {
ui.label(RichText::new(col.title).size(11.0).color(p.muted));
}
});
}
})
.body(|body| {
body.rows(row_height, rows, |mut row| {
let index = row.index();
row.set_selected(is_selected(index));
add_row(index, &mut row);
if row.response().clicked() {
clicked = Some(index);
}
});
});
clicked
}
pub fn with_icon(
ui: &mut egui::Ui,
p: &Palette,
icon: Icon,
color: egui::Color32,
primary: &str,
secondary: &str,
) {
let size = if is_compact(ui) { 18.0 } else { 30.0 };
let (rect, _) = ui.allocate_exact_size(egui::vec2(size, size), Sense::hover());
ui.painter().rect_filled(
rect,
egui::CornerRadius::same(7),
color.gamma_multiply(0.12),
);
icons::paint(ui.painter(), rect.shrink(size * 0.22), icon, color);
ui.add_space(4.0);
two_line(ui, p, primary, secondary);
}
pub fn two_line(ui: &mut egui::Ui, p: &Palette, primary: &str, secondary: &str) {
let font = egui::FontId::new(13.5, crate::theme::semibold());
let primary_text = |ui: &egui::Ui| {
RichText::new(fit_middle(ui, primary, &font))
.font(font.clone())
.color(p.text)
};
if is_compact(ui) {
let primary_text = primary_text(ui);
label(ui, primary_text);
label(ui, RichText::new(secondary).size(12.0).color(p.muted));
return;
}
ui.vertical(|ui| {
ui.add_space(6.0);
let primary_text = primary_text(ui);
label(ui, primary_text);
label(ui, RichText::new(secondary).size(12.0).color(p.muted));
});
}
fn fit_middle(ui: &egui::Ui, text: &str, font: &egui::FontId) -> String {
let width = ui.available_width();
let measure = |s: String| {
ui.fonts_mut(|f| f.layout_no_wrap(s, font.clone(), egui::Color32::PLACEHOLDER))
.size()
.x
};
if measure(text.to_owned()) <= width {
return text.to_owned();
}
let chars: Vec<char> = text.chars().collect();
let (mut lo, mut hi) = (0, chars.len());
while lo < hi {
let mid = (lo + hi).div_ceil(2);
if measure(squeeze(&chars, mid)) <= width {
lo = mid;
} else {
hi = mid - 1;
}
}
squeeze(&chars, lo)
}
fn squeeze(chars: &[char], keep: usize) -> String {
let head = keep / 3;
let tail = keep - head;
let mut out: String = chars[..head].iter().collect();
out.push('…');
out.extend(&chars[chars.len() - tail..]);
out
}
pub fn row_hovered(ui: &egui::Ui) -> bool {
let cell = ui.max_rect();
ui.ctx().pointer_hover_pos().is_some_and(|pos| {
ui.clip_rect().contains(pos) && pos.y >= cell.top() && pos.y < cell.bottom()
})
}
pub fn text(ui: &mut egui::Ui, p: &Palette, value: &str) {
label(ui, RichText::new(value).color(p.text));
}
pub fn muted(ui: &mut egui::Ui, p: &Palette, value: &str) {
label(ui, RichText::new(value).color(p.muted));
}
pub fn mono(ui: &mut egui::Ui, p: &Palette, value: &str) {
label(ui, RichText::new(value).monospace().color(p.muted));
}
fn label(ui: &mut egui::Ui, text: RichText) {
ui.add(egui::Label::new(text).truncate().selectable(false));
}
#[cfg(test)]
mod tests {
use super::{squeeze, step};
#[test]
fn squeezes_keeping_more_of_the_end() {
let chars: Vec<char> = "mirror.gcr.io/catthehacker/ubuntu:act-22.04"
.chars()
.collect();
assert_eq!(squeeze(&chars, 21), "mirror.…untu:act-22.04");
assert_eq!(squeeze(&chars, 0), "…");
}
#[test]
fn steps_through_rows() {
assert_eq!(step(None, 1, 3), Some(0));
assert_eq!(step(None, -1, 3), Some(2));
assert_eq!(step(Some(0), 1, 3), Some(1));
assert_eq!(step(Some(2), 1, 3), None);
assert_eq!(step(Some(0), -1, 3), None);
assert_eq!(step(Some(1), 0, 3), None);
assert_eq!(step(None, 1, 0), None);
}
}