Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/facett

facett-app/src/widgets.rs

//! **Input/data widgets (Fas 4).** Themed, headless-addressable primitives that
//! add a semantic + observable layer over raw egui: [`Accordion`] (collapsible
//! sections), [`Stepper`] (bounded integer), [`Pagination`] (pager). All state
//! lives in the struct (FC-1), every one exposes `state_json` (FC-3).

use serde_json::{Value, json};
use std::collections::BTreeSet;

// ── Accordion ─────────────────────────────────────────────────────────────────

/// Collapsible sections. Open-state lives here (FC-1); content is supplied per
/// call via a closure (not stored), so it stays a plain reusable widget.
#[derive(Default)]
pub struct Accordion {
    open: BTreeSet<String>,
    single_open: bool,
}

impl Accordion {
    pub fn new() -> Self {
        Self::default()
    }
    /// Only one section open at a time (an "exclusive" accordion).
    pub fn single_open(mut self, on: bool) -> Self {
        self.single_open = on;
        self
    }
    pub fn is_open(&self, id: &str) -> bool {
        self.open.contains(id)
    }
    pub fn set_open(&mut self, id: &str, open: bool) {
        if open {
            if self.single_open {
                self.open.clear();
            }
            self.open.insert(id.to_string());
        } else {
            self.open.remove(id);
        }
    }
    pub fn toggle(&mut self, id: &str) {
        let now = !self.is_open(id);
        self.set_open(id, now);
    }

    /// Draw one section: a clickable header + (when open) its `add_contents`.
    pub fn section(&mut self, ui: &mut egui::Ui, id: &str, header: &str, add_contents: impl FnOnce(&mut egui::Ui)) {
        let open = self.is_open(id);
        let arrow = if open { "▾" } else { "▸" };
        if ui.add(egui::Button::new(format!("{arrow}  {header}")).frame(false)).clicked() {
            self.toggle(id);
        }
        if self.is_open(id) {
            egui::Frame::group(ui.style()).show(ui, |ui| {
                ui.set_width(ui.available_width());
                add_contents(ui);
            });
        }
    }

    pub fn state_json(&self) -> Value {
        json!({ "open": self.open.iter().collect::<Vec<_>>(), "single_open": self.single_open })
    }
}

// ── Stepper ───────────────────────────────────────────────────────────────────

/// A bounded integer stepper: `[−] value [+]`.
pub struct Stepper {
    value: i64,
    min: i64,
    max: i64,
    step: i64,
}

impl Stepper {
    pub fn new(value: i64) -> Self {
        Self { value, min: i64::MIN, max: i64::MAX, step: 1 }
    }
    pub fn range(mut self, min: i64, max: i64) -> Self {
        self.min = min;
        self.max = max;
        self.value = self.value.clamp(min, max);
        self
    }
    pub fn step(mut self, step: i64) -> Self {
        self.step = step.max(1);
        self
    }
    pub fn value(&self) -> i64 {
        self.value
    }
    pub fn set_value(&mut self, v: i64) {
        self.value = v.clamp(self.min, self.max);
    }

    /// Draw it; returns the new value if it changed this frame.
    pub fn show(&mut self, ui: &mut egui::Ui) -> Option<i64> {
        let before = self.value;
        ui.horizontal(|ui| {
            if ui.add_enabled(self.value > self.min, egui::Button::new("−")).clicked() {
                self.set_value(self.value - self.step);
            }
            ui.label(self.value.to_string());
            if ui.add_enabled(self.value < self.max, egui::Button::new("+")).clicked() {
                self.set_value(self.value + self.step);
            }
        });
        (self.value != before).then_some(self.value)
    }

    pub fn state_json(&self) -> Value {
        json!({ "value": self.value, "min": self.min, "max": self.max, "step": self.step })
    }
}

// ── Pagination ────────────────────────────────────────────────────────────────

/// A pager over `total` items in pages of `page_size`. `page` is 0-based.
pub struct Pagination {
    page: usize,
    total: usize,
    page_size: usize,
}

impl Pagination {
    pub fn new(total: usize, page_size: usize) -> Self {
        Self { page: 0, total, page_size: page_size.max(1) }
    }
    pub fn page(&self) -> usize {
        self.page
    }
    pub fn page_count(&self) -> usize {
        self.total.div_ceil(self.page_size).max(1)
    }
    pub fn set_page(&mut self, page: usize) {
        self.page = page.min(self.page_count() - 1);
    }
    pub fn set_total(&mut self, total: usize) {
        self.total = total;
        self.set_page(self.page);
    }
    /// The `[start, end)` item indices on the current page.
    pub fn range(&self) -> (usize, usize) {
        let start = self.page * self.page_size;
        (start.min(self.total), (start + self.page_size).min(self.total))
    }
    pub fn next_page(&mut self) -> bool {
        if self.page + 1 < self.page_count() {
            self.page += 1;
            true
        } else {
            false
        }
    }
    pub fn prev_page(&mut self) -> bool {
        if self.page > 0 {
            self.page -= 1;
            true
        } else {
            false
        }
    }

    /// Draw `⏮ ◀ page x/y ▶ ⏭`; returns the new page if it changed.
    pub fn show(&mut self, ui: &mut egui::Ui) -> Option<usize> {
        let before = self.page;
        let last = self.page_count() - 1;
        ui.horizontal(|ui| {
            if ui.add_enabled(self.page > 0, egui::Button::new("⏮")).clicked() {
                self.page = 0;
            }
            if ui.add_enabled(self.page > 0, egui::Button::new("◀")).clicked() {
                self.prev_page();
            }
            ui.label(format!("page {}/{}", self.page + 1, self.page_count()));
            if ui.add_enabled(self.page < last, egui::Button::new("▶")).clicked() {
                self.next_page();
            }
            if ui.add_enabled(self.page < last, egui::Button::new("⏭")).clicked() {
                self.page = last;
            }
        });
        (self.page != before).then_some(self.page)
    }

    pub fn state_json(&self) -> Value {
        let (start, end) = self.range();
        json!({
            "page": self.page,
            "page_count": self.page_count(),
            "page_size": self.page_size,
            "total": self.total,
            "range": [start, end],
        })
    }
}

// ── CopyableBlock ─────────────────────────────────────────────────────────────

/// The label on the block's explicit copy button. Public so a caller's test can
/// address the button by the SAME string the widget paints — a test that hard-codes
/// its own copy of the label passes while the button says something else.
pub const COPY_LABEL: &str = "⧉ kopiera";

/// **The copy affordance on its own**, for content that must NOT become a text block.
///
/// [`CopyableBlock`] is right when the lines are text. It is WRONG when the rows are
/// interaction — a `selectable_label` list that picks a repo, a table whose rows drill
/// down — because turning those into one `TextEdit` would take the clicking away to
/// give the copying. Those panes get this instead: the rows stay widgets, and one
/// button hands the whole rendered listing over.
///
/// It is also what `CopyableBlock` itself draws, so there is ONE button, one label and
/// one clipboard call in the constellation rather than a copy per pane (LAW 5).
///
/// Returns whether it was pressed THIS frame — applied output, not a stored flag. An
/// empty payload disarms the button: copying "" over a full clipboard is the same
/// silent damage as copying the wrong thing.
pub fn copy_button(ui: &mut egui::Ui, text: &str) -> bool {
    let armed = !text.is_empty();
    let hit = ui.add_enabled(armed, egui::Button::new(COPY_LABEL).small()).clicked();
    if hit {
        ui.ctx().copy_text(text.to_owned());
    }
    let n = if text.is_empty() { 0 } else { text.lines().count() };
    ui.label(egui::RichText::new(format!("{n} rader")).weak().small());
    hit
}

/// **A block of log / console / snippet text that can actually be COPIED.**
///
/// ## Why this exists (measured, egui 0.35.0)
///
/// The shape it replaces is `N` separate `ui.label`s inside a clipped or scrolled
/// area. egui's cross-label selection is held by ONE context-wide plugin,
/// `LabelSelectionState`, and `on_end_pass` says, verbatim:
///
/// ```text
/// // We didn't see both cursors this frame,
/// // maybe because they are outside the visible area (scrolling),
/// // or one disappeared. In either case we will have horrible glitches, so let's just deselect.
/// ```
/// (`egui-0.35.0/src/text_selection/label_text_selection.rs:220-224`)
///
/// So the instant either end of the selection leaves the visible rect — a scroll, a
/// `stick_to_bottom` yank on a live log, a `shrink_clip_rect` from a scene leaf — the
/// selection is dropped, `text_to_copy` is emptied, and `copy_text` is never called at
/// all. The clipboard then still holds whatever was in it BEFORE, which is why the
/// symptom reads as "Ctrl+C copied the wrong text" rather than "Ctrl+C did nothing".
///
/// ## What this does instead
///
/// * **One widget, one galley.** A read-only `TextEdit::multiline` over the joined
///   text. `TextEdit` keeps its selection in its own `TextEditState`, and is the one
///   text widget in egui that `LabelSelectionState` never touches (grep: the plugin is
///   referenced only by `label.rs`, `hyperlink.rs` and `atom_layout.rs`). Scrolling
///   cannot deselect it.
/// * **An explicit button.** Selection-copy is a convenience; the button is the
///   guarantee. It hands the WHOLE block to the clipboard in one call that no
///   viewport movement can interrupt.
/// * **`follow` yields to the pointer.** `stick_to_bottom` is suspended while the
///   pointer is down, so a live log cannot yank the viewport out from under a drag.
pub struct CopyableBlock<'a> {
    id_salt: &'a str,
    text: &'a str,
    max_height: Option<f32>,
    follow: bool,
    copy_button: bool,
    monospace: bool,
}

/// What [`CopyableBlock::show`] gives back: whether the button was pressed THIS frame
/// (applied output, not a stored flag) and the text widget's own response.
pub struct CopyableBlockOut {
    pub copied: bool,
    pub response: egui::Response,
}

impl<'a> CopyableBlock<'a> {
    /// `id_salt` must be distinct per block — it salts BOTH the scroll area and the
    /// text widget, so two blocks of the same sort no longer bleed scroll offset and
    /// cursor into each other.
    pub fn new(id_salt: &'a str, text: &'a str) -> Self {
        Self { id_salt, text, max_height: None, follow: false, copy_button: true, monospace: true }
    }
    /// Bound the block's height; it scrolls inside that.
    pub fn max_height(mut self, h: f32) -> Self {
        self.max_height = Some(h);
        self
    }
    /// Keep the newest line in view — but never while the pointer is down.
    pub fn follow(mut self, on: bool) -> Self {
        self.follow = on;
        self
    }
    pub fn copy_button(mut self, on: bool) -> Self {
        self.copy_button = on;
        self
    }
    pub fn monospace(mut self, on: bool) -> Self {
        self.monospace = on;
        self
    }

    /// Convenience for the overwhelmingly common caller: a list of lines it already
    /// holds. Joining here rather than at every call site is the point — one writer.
    pub fn join(lines: impl IntoIterator<Item = impl AsRef<str>>) -> String {
        lines.into_iter().map(|l| l.as_ref().to_owned()).collect::<Vec<_>>().join("\n")
    }

    pub fn show(self, ui: &mut egui::Ui) -> CopyableBlockOut {
        let mut copied = false;
        if self.copy_button {
            ui.horizontal(|ui| {
                copied = copy_button(ui, self.text);
            });
        }
        // The pointer-down suspension. `is_using_pointer` is true for the whole of a
        // press-drag-release, which is exactly the window in which a viewport yank
        // would ruin a selection or a scroll gesture.
        let follow = self.follow && !ui.ctx().egui_is_using_pointer();
        let mut area = egui::ScrollArea::vertical()
            .id_salt(("facett_copyable_block", self.id_salt))
            .auto_shrink([false, true])
            .stick_to_bottom(follow);
        if let Some(h) = self.max_height {
            area = area.max_height(h);
        }
        let inner = area.show(ui, |ui| {
            let mut buf: &str = self.text;
            let mut te = egui::TextEdit::multiline(&mut buf)
                .id_salt(("facett_copyable_block_text", self.id_salt))
                .desired_width(f32::INFINITY)
                .frame(egui::Frame::NONE);
            if self.monospace {
                te = te.font(egui::TextStyle::Monospace);
            }
            ui.add(te)
        });
        CopyableBlockOut { copied, response: inner.inner }
    }
}