Skip to content

spreadsheet

26 × 10,000 セルの数式: メモ化を 2 段階、下書きは画面外にスクロールしても消えない。

セルに数値を打てば、それを読んでいる合計がすべて動きます。数式を打てば、シートはもう一度パースされ、順序づけられ、評価されます。これは別々の仕事で、その2 つを区別することがこのサンプルの主題です。どのメッセージもstructure_rev(どのセルが数式か、何を読むか、どういう順で評価できるか)かvalue_rev(変わったのは数値だけ)のどちらかを進め、それぞれにuse_memo が 1 つぶら下がります。ステータスバーにはそれぞれが何回走ったかが出るので、「リテラルのセルに 12 と打ってもシートはパースし直されない」ことを数字で見られます。

もう半分は、どの状態がアイテムのもので、どれが全体のものかという話です。そしてその問いを突きつけてくるのが <VirtualList> です。画面外にスクロールした行はアンマウントされ、そのフックは掃除されます。だから文書(セルのテキスト、列幅)は上のほうの use_persisted に置きます。選択、エディタ、その下書きも、同じく上のほうの reducer に置きます。スクロールしたら下書きが消えた、ではバグだからです。セルに残すのは、再マウントでリセットされるべき 状態だけです。「最初のフレームでフォーカスを取れ」「いま値が変わったから光れ」など。打ちかけの数式を画面の外へやって戻してみてください。ちゃんと残っています。

行のクロージャは、二度借りられないものを何も借りていません。2 つの memo の結果(どちらも &'s なので何とでも共存します)と Copy なスナップショットだけで、State ガードは 1 つもありません。セルがしたことはイベントとして上がり、行がそれをメッセージに変え、書き込むのは reducer だけです。固定されたヘッダは<VirtualList on_scroll> で本体に付いていきます。

自分で動かす ​

sh
cargo run -p spreadsheet
trunk serve --config examples/spreadsheet/Trunk.toml

ソースは examples/spreadsheet/src/lib.rs です。

rust
use std::cell::Cell as MutCell;

use board::hooks::{Undoable, use_undoable};
use egui_reactor::prelude::*;
use egui_reactor_elements::prelude::*;

pub mod eval;
pub mod formula;
pub mod preset;
pub mod sheet;

use eval::{Compiled, Values};
use formula::Value;
use sheet::{CellRef, Msg, Range, Sheet, reduce};

pub use sheet::{COLS, DEFAULT_COL_W, ROWS};

/// The height of one row, and of the column header.
pub const ROW_H: f32 = 22.0;
/// The width of the row-number gutter on the left.
pub const ROW_HDR_W: f32 = 52.0;
/// How narrow a column may be dragged.
pub const MIN_COL_W: f32 = 24.0;
/// How long a cell stays tinted after its value changed.
pub const FLASH_SECS: f64 = 0.6;

// ---------------------------------------------------------------------------
// 1. The UI state: everything that is not the document
// ---------------------------------------------------------------------------

/// What everything on screen sends to the sheet's reducer.
///
/// `Dispatch` is `Clone + Send + 'static`, which is what lets it travel by
/// context while a `State` guard could not (ARCHITECTURE 3.5).
pub type Actions = Dispatch<Undoable<Msg>>;

/// The same, for the UI state next door.
pub type UiActions = Dispatch<UiMsg>;

/// The cell being edited: which one, what has been typed into it, and whether
/// the field should select everything on its first frame.
///
/// The draft is here rather than in the cell because the cell may be unmounted
/// at any moment: `<VirtualList>` sweeps the hooks of a row that scrolls out of
/// view, and a draft that disappeared because the user looked at row 500 would
/// be a bug rather than a saving.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Editor {
    pub at: CellRef,
    pub draft: String,
    pub select_all: bool,
}

/// A copied rectangle, as raw text at the references it was copied from.
///
/// Snapshotted at copy time, so a paste needs nothing from the sheet: it is
/// `formula::shift` over these, by the distance between `range.from` and where
/// the cursor now is.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Clip {
    pub range: Range,
    pub cells: Vec<(CellRef, String)>,
}

/// Everything the screen knows that the document does not.
#[derive(Clone, Debug, PartialEq)]
pub struct UiState {
    /// Where a Shift-extend or a drag started.
    pub anchor: CellRef,
    /// The active cell. The selection is `Range::new(anchor, cursor)`.
    pub cursor: CellRef,
    pub editor: Option<Editor>,
    pub clipboard: Option<Clip>,
    /// The column being resized and its live width, while the drag lasts. The
    /// sheet is written once, on release.
    pub dragging_col: Option<(u16, f32)>,
}

impl Default for UiState {
    fn default() -> Self {
        Self {
            anchor: CellRef::new(0, 0),
            cursor: CellRef::new(0, 0),
            editor: None,
            clipboard: None,
            dragging_col: None,
        }
    }
}

impl UiState {
    /// The rectangle the selection covers.
    pub fn selection(&self) -> Range {
        Range::new(self.anchor, self.cursor)
    }
}

/// How an edit ended, as the cell editor saw it.
///
/// Read from the field's own `Response` rather than from the app-level key
/// handler, because egui decides Tab and Escape in `Memory::begin_pass`, before
/// any of this code runs; see [`read_grid_keys`].
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Finish {
    /// Enter: commit and step down.
    Down,
    /// Tab: commit and step right.
    Right,
    /// Escape: throw the draft away.
    Cancel,
    /// The focus went somewhere else: commit where we stand.
    Blur,
}

/// Everything that can change the UI state. One message per user action, the
/// same rule the sheet's own [`Msg`] follows.
#[derive(Clone, Debug, PartialEq)]
pub enum UiMsg {
    /// A click: anchor and cursor both move. Commits an open editor first.
    Select(CellRef),
    /// Shift-click or a drag across the grid: the cursor moves, the anchor
    /// stays where it was.
    Extend(CellRef),
    /// An arrow key. Clamped to the sheet, and commits an open editor first.
    Move {
        dcol: i32,
        drow: i32,
        extend: bool,
    },
    Edit {
        at: CellRef,
        draft: String,
        select_all: bool,
    },
    /// Every keystroke, from the cell editor or from the formula bar — they
    /// edit the same draft, which is why moving between them is not a commit.
    Draft(String),
    /// Write the draft and optionally step. `then: None` is "commit where we
    /// stand", which is what losing focus means.
    Commit {
        then: Option<(i32, i32)>,
    },
    Cancel,
    /// The text is snapshotted by the caller: the reducer has no sheet to read
    /// (see the note on [`ui_reduce`]).
    Copy(Clip),
    Paste,
    Clear,
    /// The live width while a column edge is dragged; `None` on release.
    DragCol(Option<(u16, f32)>),
}

/// Apply one UI message, returning the sheet message it implies.
///
/// The plan has this closure capture the sheet's `Dispatch` and send from
/// inside. Returning the message instead keeps the whole reducer a plain
/// function of its arguments — so the table of "what does Enter in a cell
/// actually do" is a unit test at the bottom of this file rather than a kittest
/// scenario — and `App` does the sending, one line up. No message ever implies
/// two.
///
/// A reducer that feeds another reducer is fine either way: `Dispatch::send`
/// only queues and asks for a repaint, and the sheet's reducer applies it at
/// its next visit, which is the next frame — the same one-frame delay every
/// handler write has (ARCHITECTURE 5.7).
pub fn ui_reduce(ui: &mut UiState, msg: UiMsg) -> Option<Msg> {
    match msg {
        UiMsg::Select(at) => {
            let done = commit(ui);
            ui.anchor = at;
            ui.cursor = at;
            done
        }

        UiMsg::Extend(at) => {
            // A drag across the grid reports this on every frame it moves, so
            // it must be cheap and idempotent: it is one assignment.
            if ui.cursor == at {
                return None;
            }
            let done = commit(ui);
            ui.cursor = at;
            done
        }

        UiMsg::Move { dcol, drow, extend } => {
            let done = commit(ui);
            let col = (i64::from(ui.cursor.col) + i64::from(dcol)).clamp(0, i64::from(COLS) - 1);
            let row = (i64::from(ui.cursor.row) + i64::from(drow)).clamp(0, i64::from(ROWS) - 1);
            ui.cursor = CellRef::new(col as u16, row as u32);
            if !extend {
                ui.anchor = ui.cursor;
            }
            done
        }

        UiMsg::Edit {
            at,
            draft,
            select_all,
        } => {
            // Opening an editor somewhere else commits the one that was open.
            let done = if ui.editor.as_ref().is_some_and(|e| e.at != at) {
                commit(ui)
            } else {
                None
            };
            ui.anchor = at;
            ui.cursor = at;
            ui.editor = Some(Editor {
                at,
                draft,
                select_all,
            });
            done
        }

        UiMsg::Draft(text) => {
            if let Some(editor) = &mut ui.editor {
                editor.draft = text;
                // The first frame is over; whatever the caret does now is the
                // user's business.
                editor.select_all = false;
            }
            None
        }

        UiMsg::Commit { then } => {
            let done = commit(ui);
            if let Some((dcol, drow)) = then {
                ui_reduce(
                    ui,
                    UiMsg::Move {
                        dcol,
                        drow,
                        extend: false,
                    },
                );
            }
            done
        }

        UiMsg::Cancel => {
            ui.editor = None;
            None
        }

        UiMsg::Copy(clip) => {
            ui.clipboard = Some(clip);
            None
        }

        UiMsg::Paste => {
            let clip = ui.clipboard.as_ref()?;
            let dcol = i32::from(ui.cursor.col) - i32::from(clip.range.from.col);
            let drow = ui.cursor.row as i32 - clip.range.from.row as i32;
            let cells: Vec<(CellRef, String)> = clip
                .cells
                .iter()
                .filter_map(|(at, text)| {
                    // A cell that would land outside the sheet is dropped; the
                    // references *inside* the text that would land outside
                    // become `#REF!`, which is `formula::shift`'s business.
                    let to = sheet::shifted(*at, dcol, drow)?;
                    Some((to, formula::shift(text, dcol, drow)))
                })
                .collect();
            (!cells.is_empty()).then_some(Msg::SetMany(cells))
        }

        UiMsg::Clear => {
            ui.editor = None;
            Some(Msg::Clear(ui.selection()))
        }

        UiMsg::DragCol(live) => {
            ui.dragging_col = live;
            None
        }
    }
}

/// Close the editor, if one is open, and say what to write.
fn commit(ui: &mut UiState) -> Option<Msg> {
    let editor = ui.editor.take()?;
    Some(Msg::Set {
        at: editor.at,
        text: editor.draft,
    })
}

/// The raw text of every cell in `range` that holds something.
///
/// This is what `Ctrl+C` puts in the clipboard. It is built by the caller
/// rather than by the reducer because the reducer has no sheet: the UI state
/// and the document are two reducers, and only one of them owns the text.
pub fn clip(sheet: &Sheet, range: Range) -> Clip {
    let mut cells: Vec<(CellRef, String)> = sheet
        .cells
        .iter()
        .filter(|(at, _)| range.contains(**at))
        .map(|(at, text)| (*at, text.clone()))
        .collect();
    cells.sort_by_key(|(at, _)| *at);
    Clip { range, cells }
}

// ---------------------------------------------------------------------------
// 2. The app
// ---------------------------------------------------------------------------

/// How often each memo stage has run.
///
/// `MutCell`s behind a `use_handle`, written through `Handle::with`: a
/// dirtying write would ask for a repaint from inside a memo closure and the
/// app would never idle (ARCHITECTURE 5.6). The numbers are only for the
/// status bar — and for the test that reads it, which is how "a literal edit
/// does not re-parse" is checked from the outside.
#[derive(Clone, Default)]
pub struct Counters {
    compiled: MutCell<u32>,
    evaluated: MutCell<u32>,
}

/// The dispatcher for the document, if this is drawn under one.
#[hook]
fn use_actions(cx: &mut Cx) -> Option<Actions> {
    use_context::<Actions>(cx).map(|actions| actions.get())
}

/// The dispatcher for the screen's own state.
#[hook]
fn use_ui(cx: &mut Cx) -> Option<UiActions> {
    use_context::<UiActions>(cx).map(|ui| ui.get())
}

fn send_ui(ui: &Option<UiActions>, msg: UiMsg) {
    if let Some(ui) = ui {
        ui.send(msg);
    }
}

/// The cell editor's egui id, fixed because there is only ever one.
///
/// Knowing it is what lets the formula bar and the grid tell "the focus went
/// to the other field" from "the focus went away".
fn editor_id() -> egui::Id {
    egui::Id::new("spreadsheet/cell-editor")
}

/// The formula bar's egui id, for the same reason.
fn formula_id() -> egui::Id {
    egui::Id::new("spreadsheet/formula-bar")
}

#[component]
pub fn App(cx: &mut Cx) {
    // The reducer owns the sheet and the persisted slot mirrors it, the way
    // `board` and `patch` do. `use_persisted` first, so its value is there to
    // seed the history on the very first frame.
    let mut saved = use_persisted(cx, "spreadsheet/sheet", Sheet::empty);
    let (history, dispatch) = use_undoable(cx, reduce, || saved.clone());
    if *saved != history.present {
        *saved = history.present.clone();
    }
    let actions = use_handle(cx, || dispatch.clone());

    // The screen's own state, in a reducer of its own. It sends to the sheet's
    // reducer, which is a queue and a repaint request, not a borrow.
    let to_sheet = dispatch.clone();
    let (ui_state, ui_dispatch) = use_reducer(
        cx,
        move |ui: &mut UiState, msg: UiMsg| {
            if let Some(msg) = ui_reduce(ui, msg) {
                to_sheet.send(Undoable::Do(msg));
            }
        },
        UiState::default,
    );
    let ui_handle = use_handle(cx, || ui_dispatch.clone());

    // How many times the history has been walked, which is the one number here
    // that never goes backwards.
    //
    // A revision counter does: undo restores an older sheet and its older
    // counter with it, so two different sheets can carry the same
    // `structure_rev` — undo a formula, then type a different one, and the
    // number is the one it already was. A memo compares its deps with the
    // *previous* value, and in practice a frame is drawn between any two
    // messages, so it would see the intermediate revision and recompute
    // anyway. That is a fact about timing rather than a property of the deps,
    // and it stops being true the moment two messages land in one visit to the
    // reducer. `(rev, epoch)` is monotonic, so the question does not arise.
    let mut epoch = use_state(cx, || 0u64);
    let counters = use_handle(cx, Counters::default);
    // Where the body is scrolled to, reported by the list itself and written
    // only when it changes (ARCHITECTURE 5.6). The headers are drawn before
    // the body, so they use the offset from the frame before — the standard
    // one-frame delay, and invisible at sixty frames a second.
    let mut offset = use_state(cx, egui::Vec2::default);
    let mut seeded = use_state(cx, || false);
    // Set on the frame the grid takes Tab, cleared on the next one; see
    // [`read_grid_keys`].
    let mut tabbed = use_state(cx, || false);

    if *tabbed {
        *tabbed = false;
        // egui gave the focus to the first widget that wanted it when the grid
        // took the Tab. Take it back, so the next key still reaches the grid.
        cx.ctx().memory_mut(|m| {
            if let Some(id) = m.focused() {
                m.surrender_focus(id);
            }
        });
    }

    // Seeded once per mount, and only into a sheet nobody has touched, so a
    // saved sheet is never overwritten by the preset.
    if !*seeded {
        *seeded = true;
        if history.present.is_empty() {
            dispatch.send(Undoable::Do(Msg::Load(Box::new(preset::starter()))));
        }
    }

    let sheet = &history.present;
    let steps = *epoch;

    // Stage one: the program. Every formula parsed, every dependency
    // collected, the whole lot put in an order that can be walked once. Only a
    // *formula* can change any of that, which is what `structure_rev` counts.
    let compiled: &Compiled = use_memo(cx, (sheet.structure_rev, steps), || {
        counters.with(|c| c.compiled.set(c.compiled.get() + 1));
        eval::compile(sheet)
    });
    // Stage two: the numbers. Any keystroke can change these, and this is the
    // one that runs when a literal cell is typed into.
    let values: &Values = use_memo(cx, (sheet.value_rev, sheet.structure_rev, steps), || {
        counters.with(|c| c.evaluated.set(c.evaluated.get() + 1));
        eval::evaluate(sheet, compiled)
    });

    // Read once, into locals: an element may not hold a shared borrow of a
    // state *and* a handler that writes it (ARCHITECTURE 3.7).
    let cursor = ui_state.cursor;
    let selection = ui_state.selection();
    let editing = ui_state.editor.as_ref().map(|e| e.at);
    let draft = ui_state
        .editor
        .as_ref()
        .map_or_else(String::new, |e| e.draft.clone());
    let select_all = ui_state.editor.as_ref().is_some_and(|e| e.select_all);
    let dragging_col = ui_state.dragging_col;
    let seen = *offset;
    let (compiled_runs, evaluated_runs) = counters.with(|c| (c.compiled.get(), c.evaluated.get()));

    // The widths the grid is drawn at: the sheet's, with the column being
    // dragged overridden, so the body follows the drag live while the document
    // is written once, on release.
    let widths: Vec<f32> = (0..COLS)
        .map(|col| match dragging_col {
            Some((dragged, w)) if dragged == col => w,
            _ => sheet.width(col),
        })
        .collect();
    let total_w: f32 = widths.iter().sum();

    // The keys the grid takes, before anything is drawn.
    let focused = cx.ctx().memory(|m| m.focused());
    if focused.is_none() {
        let took_tab = {
            let keys = Keys {
                sheet,
                cursor,
                selection,
                ui: &ui_dispatch,
                actions: &dispatch,
            };
            read_grid_keys(cx.ui(), &keys)
        };
        if took_tab.walked {
            *epoch += 1;
        }
        if took_tab.tabbed {
            *tabbed = true;
        }
    }

    let formula_text = if editing.is_some() {
        draft.clone()
    } else {
        sheet.text(cursor).to_owned()
    };
    let status = format!(
        "{COLS} × {ROWS} · {} cells · compiled {compiled_runs} · evaluated {evaluated_runs}",
        sheet.len()
    );
    let inspector = inspect(sheet, compiled, values, cursor);
    let (can_undo, can_redo) = (history.can_undo(), history.can_redo());

    let view = rsx! {
        <View direction="column" grow={1.0} w="100%" h="100%" gap={0}>
            <Toolbar
                cursor={cursor}
                selection={selection}
                formula={formula_text.as_str()}
                status={status.as_str()}
                inspector={inspector.as_str()}
                can_undo={can_undo}
                can_redo={can_redo}
                on_undo={|| {
                    *epoch += 1;
                    dispatch.send(Undoable::Undo);
                }}
                on_redo={|| {
                    *epoch += 1;
                    dispatch.send(Undoable::Redo);
                }}
            />
            <Separator/>
            // The two headers and the body all read the same offset, which is
            // why the corner, the column letters and the row numbers stay
            // lined up with the cells while either axis scrolls.
            <View direction="row" w="100%" h={ROW_H} shrink={0.0}>
                <View w={ROW_HDR_W} h={ROW_H} shrink={0.0}/>
                <ColumnHeader
                    grow={1.0}
                    h={ROW_H}
                    widths={widths.as_slice()}
                    selection={selection}
                    offset_x={seen.x}
                />
            </View>
            <View direction="row" grow={1.0} h={0.0} w="100%">
                <RowHeader
                    w={ROW_HDR_W}
                    shrink={0.0}
                    h="100%"
                    selection={selection}
                    offset_y={seen.y}
                />
                // `grow` for the width and `h="100%"` for the height: inside a
                // row the main axis is horizontal, so `grow` says "take what is
                // left across" and the height has to be spelled out. A filling
                // leaf with an `auto` height would ask for the whole window and
                // push the toolbar off the top (ARCHITECTURE 6).
                <VirtualList
                    grow={1.0}
                    h="100%"
                    rows={ROWS as usize}
                    row_h={ROW_H}
                    row_w={total_w}
                    horizontal
                    on_scroll={|at: egui::Vec2| {
                        // Every frame, so only a change may write.
                        if *offset != at {
                            *offset = at;
                        }
                    }}
                    render={|cx: &mut Cx<'_, '_>, row: usize| {
                        rsx! {
                            <Row
                                row={row as u32}
                                widths={widths.as_slice()}
                                values={values}
                                selection={selection}
                                cursor={cursor}
                                editing={editing == Some(CellRef::new(0, row as u32)) || editing.is_some_and(|at| at.row == row as u32)}
                                editing_col={editing.map_or(COLS, |at| at.col)}
                                draft={draft.as_str()}
                                select_all={select_all}
                            />
                        }
                        .show(cx);
                    }}
                />
            </View>
        </View>
    };

    provide_context(cx, actions, |cx| {
        provide_context(cx, ui_handle, |cx| view.show(cx))
    });
}

/// What [`read_grid_keys`] needs to do its job.
struct Keys<'a> {
    sheet: &'a Sheet,
    cursor: CellRef,
    selection: Range,
    ui: &'a UiActions,
    actions: &'a Actions,
}

/// What one frame of key handling did that the caller has to finish.
struct Took {
    /// The history was walked, so `epoch` must be bumped.
    walked: bool,
    /// Tab was taken, so the focus egui handed out has to be taken back.
    tabbed: bool,
}

/// Every key the grid answers to, in one place, once a frame.
///
/// Only when nothing has focus: the formula bar, the name box and the cell
/// editor are ordinary `TextEdit`s and keep egui's own key handling while they
/// are focused. The gallery's search box is one of those too, so typing into it
/// never reaches the grid. The cells are deliberately `Sense::CLICK |
/// Sense::DRAG` and not `Sense::click_and_drag()`, which is the same thing plus
/// `FOCUSABLE`: a focusable cell would take the keyboard focus the moment it
/// was clicked and this handler would never run again.
///
/// The keys taken here are `consume_key`d so that nothing below reacts to them
/// as well. `Event::Text` is only read, because nothing else wants it.
///
/// Two keys are not here, and both for the same reason: egui's
/// `Memory::begin_pass` reads Tab and Escape out of the raw events *before* any
/// application code runs, and only the event filter of the widget that has
/// focus can stop it. So Escape and Enter inside the editor are read from the
/// editor's own `Response` (see [`Cell`]), and the Tab the grid takes here is
/// handed to egui as well — it gives the focus to the first widget that wants
/// it, and `App` takes it back on the next frame.
///
/// What is deliberately missing: the viewport does not follow the cursor.
/// `<VirtualList>` has no `scroll_to`, arrow keys move the cursor and the name
/// box jumps it, and either can walk it off the screen. That is the second
/// thing the element would need for an application; the first was `on_scroll`.
fn read_grid_keys(ui: &mut egui::Ui, keys: &Keys<'_>) -> Took {
    use egui::{Key, Modifiers};

    let mut took = Took {
        walked: false,
        tabbed: false,
    };
    let redo = Modifiers {
        shift: true,
        command: true,
        ..Modifiers::NONE
    };

    // Collected first and sent afterwards. `Dispatch::send` asks the context
    // for a repaint, and `input_mut` is holding the context's lock while the
    // closure runs: sending from inside it deadlocks.
    let mut messages: Vec<UiMsg> = Vec::new();
    let mut history: Vec<Undoable<Msg>> = Vec::new();

    // `consume_key` matches "at least these modifiers", not "exactly these"
    // (`Modifiers::matches_logically`), so every pair below is tried with the
    // more specific pattern first: asking for a bare arrow would otherwise
    // swallow the Shift-extend, and asking for Ctrl+Z would swallow
    // Ctrl+Shift+Z.
    ui.input_mut(|i| {
        for (key, dcol, drow) in [
            (Key::ArrowLeft, -1, 0),
            (Key::ArrowRight, 1, 0),
            (Key::ArrowUp, 0, -1),
            (Key::ArrowDown, 0, 1),
        ] {
            if i.consume_key(Modifiers::SHIFT, key) {
                messages.push(UiMsg::Move {
                    dcol,
                    drow,
                    extend: true,
                });
            } else if i.consume_key(Modifiers::NONE, key) {
                messages.push(UiMsg::Move {
                    dcol,
                    drow,
                    extend: false,
                });
            }
        }

        if i.consume_key(Modifiers::SHIFT, Key::Tab) {
            took.tabbed = true;
            messages.push(UiMsg::Move {
                dcol: -1,
                drow: 0,
                extend: false,
            });
        } else if i.consume_key(Modifiers::NONE, Key::Tab) {
            took.tabbed = true;
            messages.push(UiMsg::Move {
                dcol: 1,
                drow: 0,
                extend: false,
            });
        }

        // Enter and F2 open the editor on what is already there; typing a
        // character opens it on that character. That difference is the whole of
        // "type to replace, F2 to correct".
        if i.consume_key(Modifiers::NONE, Key::Enter) || i.consume_key(Modifiers::NONE, Key::F2) {
            messages.push(UiMsg::Edit {
                at: keys.cursor,
                draft: keys.sheet.text(keys.cursor).to_owned(),
                select_all: true,
            });
        }

        if i.consume_key(Modifiers::NONE, Key::Delete)
            || i.consume_key(Modifiers::NONE, Key::Backspace)
        {
            messages.push(UiMsg::Clear);
        }

        if i.consume_key(redo, Key::Z) || i.consume_key(Modifiers::COMMAND, Key::Y) {
            took.walked = true;
            history.push(Undoable::Redo);
        } else if i.consume_key(Modifiers::COMMAND, Key::Z) {
            took.walked = true;
            history.push(Undoable::Undo);
        }
        if i.consume_key(Modifiers::COMMAND, Key::C) {
            messages.push(UiMsg::Copy(clip(keys.sheet, keys.selection)));
        }
        if i.consume_key(Modifiers::COMMAND, Key::V) {
            messages.push(UiMsg::Paste);
        }

        // Type to edit. Read rather than consumed: nothing else is focused, so
        // there is nobody to take it from. Every character of the frame goes
        // into the draft, not just the first: a burst that arrives together —
        // a fast typist, a synthetic event queue — would otherwise lose all but
        // one of its characters before the editor exists to receive them.
        let typed: String = i
            .events
            .iter()
            .filter_map(|event| match event {
                egui::Event::Text(text)
                    if !text.is_empty() && !text.chars().any(char::is_control) =>
                {
                    Some(text.as_str())
                }
                _ => None,
            })
            .collect();
        if !typed.is_empty() {
            messages.push(UiMsg::Edit {
                at: keys.cursor,
                draft: typed,
                select_all: false,
            });
        }
    });

    for msg in messages {
        keys.ui.send(msg);
    }
    for msg in history {
        keys.actions.send(msg);
    }
    took
}

/// The line under the toolbar that says what the cursor cell is made of.
fn inspect(sheet: &Sheet, compiled: &Compiled, values: &Values, at: CellRef) -> String {
    let name = at.name();
    let text = sheet.text(at);
    let (shown, ..) = display(values.get(at));
    if text.is_empty() {
        return format!("{name} is empty");
    }
    if !sheet::is_formula(text) {
        return format!("{name} = {text}");
    }
    let reads = compiled.deps.get(&at).map_or(0, Vec::len);
    let arrow = if shown.is_empty() { "—" } else { &shown };
    format!("{name} = {} → {arrow} · reads {reads} cells", &text[1..])
}

/// A value as the grid draws it: the text, whether it is a number (so it is
/// right-aligned) and whether it is an error (so it is red).
pub fn display(value: &Value) -> (String, bool, bool) {
    match value {
        Value::Empty => (String::new(), false, false),
        Value::Num(n) => (number(*n), true, false),
        Value::Text(text) => (text.clone(), false, false),
        Value::Err(error) => (String::from(error.label()), false, true),
    }
}

/// A number as a spreadsheet writes it: whole ones without a point, the rest
/// to four decimals with the trailing zeros taken off.
fn number(n: f64) -> String {
    if n == 0.0 {
        // Which also catches `-0.0`, whose `{:.0}` is `-0`.
        return String::from("0");
    }
    if n == n.trunc() && n.abs() < 1e15 {
        return format!("{n:.0}");
    }
    let mut text = format!("{n:.4}");
    while text.ends_with('0') {
        text.pop();
    }
    if text.ends_with('.') {
        text.pop();
    }
    text
}

// ---------------------------------------------------------------------------
// 3. The toolbar
// ---------------------------------------------------------------------------

/// Undo, redo, the name box, the formula bar and the two status lines.
///
/// It owns nothing but the text in the name box: everything else is handed to
/// it and everything it does goes out as a message.
#[component]
#[allow(clippy::too_many_arguments)]
fn Toolbar(
    cx: &mut Cx,
    #[prop(default)] style: ItemStyle,
    cursor: CellRef,
    selection: Range,
    formula: &str,
    status: &str,
    inspector: &str,
    can_undo: bool,
    can_redo: bool,
    #[event] on_undo: (),
    #[event] on_redo: (),
) {
    let ui = use_ui(cx);
    let mut name_box = use_state(cx, String::new);
    // Where the selection is, as its hint: the box is empty until it is typed
    // into (`clear_on_submit` empties it again), so the hint is free to say
    // where the cursor is without fighting the user for the field.
    let shown = if selection.from == selection.to {
        cursor.name()
    } else {
        format!("{}:{}", selection.from.name(), selection.to.name())
    };
    let jump = ui.clone();
    let draft_to = ui.clone();
    let focus_to = ui.clone();

    rsx! {
        <View style={style} direction="column" w="100%" gap={4} p={6} shrink={0.0}>
            <View direction="row" gap={6} align="center" w="100%">
                <Button label="undo" enabled={can_undo} on_click={|| on_undo.emit(())}>"↺"</Button>
                <Button label="redo" enabled={can_redo} on_click={|| on_redo.emit(())}>"↻"</Button>
                <Separator vertical/>
                // The name box jumps the cursor, not the view: see the note on
                // `read_grid_keys` about what `<VirtualList>` cannot do yet.
                <TextEdit
                    w={80.0}
                    bind={name_box.bind()}
                    hint={shown.as_str()}
                    clear_on_submit
                    on_submit={|text: String| {
                        if let Some(range) = parse_range(&text) {
                            send_ui(&jump, UiMsg::Select(range.from));
                            if range.from != range.to {
                                send_ui(&jump, UiMsg::Extend(range.to));
                            }
                        }
                    }}
                />
                <FormulaBar
                    grow={1.0}
                    text={formula}
                    on_draft={|text: String| send_ui(&draft_to, UiMsg::Draft(text))}
                    // Clicking into the bar with nothing being edited opens an
                    // editor on the cursor cell holding what is already there,
                    // so the bar and the cell go on editing one draft.
                    on_focus={|| send_ui(&focus_to, UiMsg::Edit {
                        at: cursor,
                        draft: String::from(formula),
                        select_all: false,
                    })}
                />
            </View>
            <Text size={11.0}>{status}</Text>
            <Text size={11.0}>{inspector}</Text>
        </View>
    }
}

/// `A1` or `A1:C3`, as the name box reads it.
fn parse_range(text: &str) -> Option<Range> {
    let text = text.trim();
    match text.split_once(':') {
        Some((from, to)) => Some(Range::new(CellRef::parse(from)?, CellRef::parse(to)?)),
        None => CellRef::parse(text).map(Range::one),
    }
}

/// The formula bar: the cursor cell's raw text, or the draft while it is being
/// edited.
///
/// Hand-written rather than `<TextEdit>` because this one has to *report* the
/// text: the element's `bind` holds the only `&mut` to the string, so a handler
/// on the same element could not read it as well (ARCHITECTURE 3.7). The buffer
/// is a copy made each frame, which is what an immediate-mode text field is
/// happy with — the same shape as `patch`'s `SourceEdit`.
#[component]
fn FormulaBar(
    cx: &mut Cx,
    #[prop(default)] style: ItemStyle,
    text: &str,
    #[event] on_draft: String,
    #[event] on_focus: (),
) {
    let mut buffer = text.to_owned();
    let (changed, gained) = cx.leaf(&style, |ui| {
        let response = ui.add(
            egui::TextEdit::singleline(&mut buffer)
                .id(formula_id())
                .hint_text("formula")
                .desired_width(ui.available_width()),
        );
        (response.changed(), response.gained_focus())
    });
    // Gaining focus opens an editor on the cursor cell if none is open, so the
    // bar and the cell edit one draft between them.
    if gained {
        on_focus.emit(());
    }
    if changed {
        on_draft.emit(buffer);
    }
}

// ---------------------------------------------------------------------------
// 4. The headers
// ---------------------------------------------------------------------------

/// The colours the grid is drawn in. Everything comes from the theme, so the
/// example follows the gallery's light / dark switch without a snapshot.
#[derive(Clone, Copy)]
struct Look {
    paper: egui::Color32,
    grid: egui::Color32,
    header: egui::Color32,
    text: egui::Color32,
    faint: egui::Color32,
    selection: egui::Color32,
    active: egui::Color32,
    flash: egui::Color32,
    error: egui::Color32,
}

fn look(ctx: &egui::Context) -> Look {
    let visuals = &ctx.style_of(ctx.theme()).visuals;
    Look {
        paper: visuals.extreme_bg_color,
        grid: visuals.widgets.noninteractive.bg_stroke.color,
        header: visuals.faint_bg_color,
        text: visuals.text_color(),
        faint: visuals.weak_text_color(),
        selection: visuals.selection.bg_fill,
        active: visuals.selection.stroke.color,
        flash: visuals.hyperlink_color,
        error: visuals.error_fg_color,
    }
}

/// `A`..`Z` across the top, and the six points on each edge that resize a
/// column.
///
/// Not a `<Canvas>`: `Canvas` hands `paint` one rect with one `sense`, and a
/// column edge needs an `interact` of its own. So it is a `cx.leaf_fill` with a
/// hand-written body, the third hatch down in `examples/escape-hatch`.
///
/// Clicking a letter does not select the column. The header is for widths;
/// selection is the grid's, and one job per surface keeps the drag
/// unambiguous.
#[component]
fn ColumnHeader(
    cx: &mut Cx,
    #[prop(default)] style: ItemStyle,
    widths: &[f32],
    selection: Range,
    offset_x: f32,
) {
    let ui_actions = use_ui(cx);
    let actions = use_actions(cx);
    let look = look(cx.ctx());

    cx.leaf_fill(&style, move |ui| {
        let (rect, _response) = ui.allocate_exact_size(ui.available_size(), egui::Sense::hover());
        let painter = ui.painter().with_clip_rect(rect);
        painter.rect_filled(rect, 0.0, look.header);

        let font = egui::FontId::proportional(11.0);
        let mut x = rect.left() - offset_x;
        for (col, w) in widths.iter().enumerate() {
            let col = col as u16;
            let cell =
                egui::Rect::from_min_size(egui::pos2(x, rect.top()), egui::vec2(*w, rect.height()));
            x += w;
            if cell.right() < rect.left() || cell.left() > rect.right() {
                continue;
            }
            if (selection.from.col..=selection.to.col).contains(&col) {
                painter.rect_filled(cell, 0.0, look.selection.gamma_multiply(0.35));
            }
            painter.text(
                cell.center(),
                egui::Align2::CENTER_CENTER,
                CellRef::new(col, 0).name().trim_end_matches('1'),
                font.clone(),
                look.text,
            );
            painter.vline(
                cell.right(),
                rect.y_range(),
                egui::Stroke::new(1.0, look.grid),
            );

            // The six points either side of the edge that drag the width.
            let edge = egui::Rect::from_min_max(
                egui::pos2(cell.right() - 3.0, rect.top()),
                egui::pos2(cell.right() + 3.0, rect.bottom()),
            );
            // `Sense::DRAG`, not `Sense::drag()`: the latter is the same thing
            // plus `FOCUSABLE`, and a resize handle that can be tabbed to but
            // carries no name is a node a screen reader can only read out as
            // "unknown". Same reasoning as the cells, one screen down.
            let handle = ui.interact(edge, ui.id().with(("edge", col)), egui::Sense::DRAG);
            if handle.hovered() || handle.dragged() {
                ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeColumn);
            }
            if handle.dragged() {
                let next = (*w + handle.drag_delta().x).max(MIN_COL_W);
                send_ui(&ui_actions, UiMsg::DragCol(Some((col, next))));
            }
            if handle.drag_stopped() {
                // One message for the whole drag, so undo walks it back in one
                // step (the same reasoning as `patch`'s `MoveNode`).
                if let Some(actions) = &actions {
                    actions.send(Undoable::Do(Msg::SetColWidth { col, w: *w }));
                }
                send_ui(&ui_actions, UiMsg::DragCol(None));
            }
        }
        painter.hline(
            rect.x_range(),
            rect.bottom(),
            egui::Stroke::new(1.0, look.grid),
        );
    });
}

/// `1`..`10000` down the left. A leaf that paints and senses nothing.
#[component]
fn RowHeader(cx: &mut Cx, #[prop(default)] style: ItemStyle, selection: Range, offset_y: f32) {
    let look = look(cx.ctx());

    cx.leaf_fill(&style, move |ui| {
        let (rect, _response) = ui.allocate_exact_size(ui.available_size(), egui::Sense::hover());
        let painter = ui.painter().with_clip_rect(rect);
        painter.rect_filled(rect, 0.0, look.header);

        let font = egui::FontId::proportional(11.0);
        let first = (offset_y / ROW_H).floor().max(0.0) as u32;
        let count = (rect.height() / ROW_H).ceil() as u32 + 1;
        for row in first..(first + count).min(ROWS) {
            let top = rect.top() + row as f32 * ROW_H - offset_y;
            let cell = egui::Rect::from_min_size(
                egui::pos2(rect.left(), top),
                egui::vec2(rect.width(), ROW_H),
            );
            if (selection.from.row..=selection.to.row).contains(&row) {
                painter.rect_filled(cell, 0.0, look.selection.gamma_multiply(0.35));
            }
            painter.text(
                cell.center(),
                egui::Align2::CENTER_CENTER,
                format!("{}", row + 1),
                font.clone(),
                look.text,
            );
            painter.hline(
                rect.x_range(),
                cell.bottom(),
                egui::Stroke::new(1.0, look.grid),
            );
        }
        painter.vline(
            rect.right(),
            rect.y_range(),
            egui::Stroke::new(1.0, look.grid),
        );
    });
}

// ---------------------------------------------------------------------------
// 5. The grid
// ---------------------------------------------------------------------------

/// One cell as the row worked it out, so that the `rsx!` below is a loop over
/// values rather than a loop with a body.
struct Shown {
    at: CellRef,
    w: f32,
    text: String,
    is_number: bool,
    is_error: bool,
}

/// One row of the grid: twenty-six cells and the messages they send.
///
/// The row is what turns "this cell was pressed" into a `UiMsg`, so a cell
/// holds no dispatcher and no state that matters. `use_ui` once per row is
/// twenty hook calls a frame; once per cell would be five hundred.
#[component]
#[allow(clippy::too_many_arguments)]
fn Row(
    cx: &mut Cx,
    row: u32,
    widths: &[f32],
    values: &Values,
    selection: Range,
    cursor: CellRef,
    editing: bool,
    editing_col: u16,
    draft: &str,
    select_all: bool,
) {
    let ui_actions = use_ui(cx);
    let shown: Vec<Shown> = (0..COLS)
        .map(|col| {
            let at = CellRef::new(col, row);
            let (text, is_number, is_error) = display(values.get(at));
            Shown {
                at,
                w: widths.get(col as usize).copied().unwrap_or(DEFAULT_COL_W),
                text,
                is_number,
                is_error,
            }
        })
        .collect();

    rsx! {
        <View direction="row" w="100%" h={ROW_H}>
            for cell in &shown {
                <Cell
                    key={cell.at.col}
                    at={cell.at}
                    width={cell.w}
                    text={cell.text.as_str()}
                    is_number={cell.is_number}
                    is_error={cell.is_error}
                    selected={selection.contains(cell.at)}
                    active={cell.at == cursor}
                    editing={editing && cell.at.col == editing_col}
                    draft={draft}
                    select_all={select_all}
                    on_press={|| send_ui(&ui_actions, UiMsg::Select(cell.at))}
                    on_shift_press={|| send_ui(&ui_actions, UiMsg::Extend(cell.at))}
                    on_drag_over={|| send_ui(&ui_actions, UiMsg::Extend(cell.at))}
                    on_double={|| send_ui(&ui_actions, UiMsg::Edit {
                        at: cell.at,
                        draft: String::new(),
                        select_all: true,
                    })}
                    on_draft={|text: String| send_ui(&ui_actions, UiMsg::Draft(text))}
                    on_finish={|how: Finish| send_ui(&ui_actions, match how {
                        Finish::Down => UiMsg::Commit { then: Some((0, 1)) },
                        Finish::Right => UiMsg::Commit { then: Some((1, 0)) },
                        Finish::Cancel => UiMsg::Cancel,
                        Finish::Blur => UiMsg::Commit { then: None },
                    })}
                />
            }
        </View>
    }
}

/// One cell: a rectangle, a value, and — when it is the one being edited — a
/// text field on top of it.
///
/// A single `cx.leaf`, because a cell is one rectangle however much is going on
/// in it. The two `use_state`s here are the ones that *should* be lost when the
/// row scrolls away: "flash, my value just changed" and "this is the editor's
/// first frame, take the focus".
#[component]
#[allow(clippy::too_many_arguments)]
fn Cell(
    cx: &mut Cx,
    at: CellRef,
    // Not `w`: `rsx!` treats every layout shorthand as an `ItemStyle`
    // attribute, so a prop called `w` would be packed into `style` instead of
    // reaching the component.
    width: f32,
    text: &str,
    is_number: bool,
    is_error: bool,
    selected: bool,
    active: bool,
    editing: bool,
    draft: &str,
    select_all: bool,
    #[event] on_press: (),
    #[event] on_shift_press: (),
    #[event] on_drag_over: (),
    #[event] on_double: (),
    #[event] on_draft: String,
    #[event] on_finish: Finish,
) {
    let look = look(cx.ctx());
    let now = cx.ui().input(|i| i.time);

    // The value this cell showed last, and when it changed. Seeded with what
    // is on screen now, so a cell that scrolls into view arrives calm — which
    // is the whole reason this state is allowed to be cell-local.
    let mut last = use_state(cx, || (text.to_owned(), None::<f64>));
    if last.0 != text {
        *last = (text.to_owned(), Some(now));
    }
    let flash = last.1.map_or(0.0, |when| {
        (1.0 - (now - when) / FLASH_SECS).clamp(0.0, 1.0)
    }) as f32;
    if flash > 0.0 {
        cx.ctx().request_repaint();
    }

    let mut was_editing = use_state(cx, || false);
    let first = editing && !*was_editing;
    if *was_editing != editing {
        *was_editing = editing;
    }

    let mut buffer = draft.to_owned();
    let style = ItemStyle::default().w(width).h(ROW_H).shrink(0.0);
    let out = cx.leaf(&style, |ui| {
        // Not `Sense::click_and_drag()`: that one is focusable, and a cell that
        // took the keyboard focus would silence the grid's key handler.
        let (rect, response) = ui.allocate_exact_size(
            egui::vec2(width, ROW_H),
            egui::Sense::CLICK | egui::Sense::DRAG,
        );
        let painter = ui.painter().with_clip_rect(rect);

        painter.rect_filled(rect, 0.0, look.paper);
        if selected {
            painter.rect_filled(rect, 0.0, look.selection.gamma_multiply(0.25));
        }
        if flash > 0.0 {
            painter.rect_filled(rect, 0.0, look.flash.gamma_multiply(0.30 * flash));
        }
        let stroke = egui::Stroke::new(1.0, look.grid);
        painter.vline(rect.right(), rect.y_range(), stroke);
        painter.hline(rect.x_range(), rect.bottom(), stroke);

        if !editing {
            let colour = if is_error {
                look.error
            } else if text.is_empty() {
                look.faint
            } else {
                look.text
            };
            let (anchor, x) = if is_number {
                (egui::Align2::RIGHT_CENTER, rect.right() - 4.0)
            } else {
                (egui::Align2::LEFT_CENTER, rect.left() + 4.0)
            };
            painter.text(
                egui::pos2(x, rect.center().y),
                anchor,
                text,
                egui::FontId::proportional(12.0),
                colour,
            );
        }
        if active {
            painter.rect_stroke(
                rect.shrink(1.0),
                0.0,
                egui::Stroke::new(2.0, look.active),
                egui::StrokeKind::Inside,
            );
        }

        // The name in the accessibility tree, which is also what a test looks
        // the cell up by, and the shown value as the node's value: the text is
        // painted, so without this a screen reader — and a test — would be told
        // there is a cell called `B2` and never what is in it. Only the cells
        // in view are in the tree, a few hundred of them.
        response.widget_info(|| {
            let mut info = egui::WidgetInfo::labeled(egui::WidgetType::Other, true, at.name());
            info.current_text_value = Some(text.to_owned());
            info
        });

        let mut changed = false;
        let mut finish = None;
        if editing {
            // `lock_focus`, so that Tab neither moves the focus nor inserts a
            // tab character: a single-line field ignores it, and egui's
            // `begin_pass` skips it because the field's event filter claims it.
            // That is what lets Tab mean "commit and step right".
            let field = ui.put(
                rect,
                egui::TextEdit::singleline(&mut buffer)
                    .id(editor_id())
                    .lock_focus(true)
                    .margin(egui::Margin::symmetric(3, 2)),
            );
            ui.ctx().accesskit_node_builder(field.id, |node| {
                node.set_label(format!("edit {}", at.name()));
            });
            if first {
                field.request_focus();
                let mut state = egui::TextEdit::load_state(ui.ctx(), field.id).unwrap_or_default();
                let end = egui::text::CCursor::new(buffer.chars().count());
                let range = if select_all {
                    egui::text::CCursorRange::two(egui::text::CCursor::new(0), end)
                } else {
                    egui::text::CCursorRange::one(end)
                };
                state.cursor.set_char_range(Some(range));
                state.store(ui.ctx(), field.id);
            }
            changed = field.changed();
            if field.has_focus()
                && ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Tab))
            {
                finish = Some(Finish::Right);
            } else if field.lost_focus() {
                let (enter, escape) = ui.input(|i| {
                    (
                        i.key_pressed(egui::Key::Enter),
                        i.key_pressed(egui::Key::Escape),
                    )
                });
                let to_bar = ui.ctx().memory(|m| m.focused()) == Some(formula_id());
                finish = if escape {
                    Some(Finish::Cancel)
                } else if enter {
                    Some(Finish::Down)
                } else if to_bar {
                    // The bar and the cell edit one draft; moving between them
                    // is not the end of the edit.
                    None
                } else {
                    Some(Finish::Blur)
                };
            }
        }

        let dragging_over = ui.rect_contains_pointer(rect)
            && ui.input(|i| i.pointer.is_decidedly_dragging() && i.pointer.primary_down());
        let shift = ui.input(|i| i.modifiers.shift);
        Painted {
            pressed: response.clicked() || response.drag_started(),
            shift,
            dragging_over,
            double: response.double_clicked(),
            changed,
            finish,
        }
    });

    // Nothing is picked up and nothing is dropped, so `board`'s `use_dnd` is
    // the wrong tool for a drag-select: a cell only reports "the pointer is
    // over me with the button down" and the row extends the selection.
    if out.dragging_over && !out.pressed {
        on_drag_over.emit(());
    }
    if out.pressed {
        if out.shift {
            on_shift_press.emit(());
        } else {
            on_press.emit(());
        }
    }
    if out.double {
        on_double.emit(());
    }
    if out.changed {
        on_draft.emit(buffer);
    }
    if let Some(how) = out.finish {
        on_finish.emit(how);
    }
}

/// What one cell's leaf reports back out of the `Ui` closure.
struct Painted {
    pressed: bool,
    shift: bool,
    dragging_over: bool,
    double: bool,
    changed: bool,
    finish: Option<Finish>,
}

MIT または Apache-2.0 ライセンスで公開しています。