ferrodock 1.8.0

A fast, native container manager for Docker, written in Rust.
//! The list table every page uses: header row, whole-row hover and click
//! to select, plus a few standard cell styles.

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 COMPACT_ROW_HEIGHT: f32 = 30.0;

/// Switches every table between two-line and one-line rows.
pub fn set_compact(ctx: &egui::Context, compact: bool) {
    ctx.data_mut(|d| d.insert_temp(compact_id(), compact));
}

/// Whether tables use one-line rows (the Compact density setting).
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")
}

/// A column: header title and sizing.
pub struct Col {
    pub title: &'static str,
    pub width: Column,
}

impl Col {
    /// Takes the space left over by the fixed columns.
    pub fn fill(title: &'static str) -> Self {
        Self {
            title,
            width: Column::remainder().at_least(160.0).clip(true),
        }
    }

    pub fn fixed(title: &'static str, width: f32) -> Self {
        Self {
            title,
            width: Column::exact(width).clip(true),
        }
    }
}

/// Asks the next table drawn to move its selection by `delta` rows (↑/↓),
/// or with 0 just to scroll the selected row into view.
pub fn request_step(ctx: &egui::Context, delta: i32) {
    ctx.data_mut(|d| d.insert_temp(step_id(), delta));
}

/// Drops a step no table picked up this frame (e.g. the list was empty).
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")
}

/// The row a step of `delta` lands on, if it moves at all.
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)
}

/// Draws a table with `rows` rows. `add_row` fills in the cells of one row
/// (one `row.col(..)` per column). Returns the index of a row that was
/// clicked or stepped to with the keyboard.
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| {
                    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
}

/// Like [`two_line`], with a coloured icon in front, e.g. a container
/// coloured by its state.
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);
}

/// Primary text with a muted second line, e.g. name over image. In
/// compact mode both go on one line.
pub fn two_line(ui: &mut egui::Ui, p: &Palette, primary: &str, secondary: &str) {
    let primary_text = RichText::new(primary)
        .font(egui::FontId::new(13.5, crate::theme::semibold()))
        .color(p.text);
    if is_compact(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);
        label(ui, primary_text);
        label(ui, RichText::new(secondary).size(12.0).color(p.muted));
    });
}

/// Whether the pointer is over the row this cell belongs to.
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));
}

/// Non-selectable, truncating label so clicks select the row instead.
fn label(ui: &mut egui::Ui, text: RichText) {
    ui.add(egui::Label::new(text).truncate().selectable(false));
}

#[cfg(test)]
mod tests {
    use super::step;

    #[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);
    }
}