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 }
}
}