Warning: This app is AI generated and human reviewed. If that bothers you, move on.
An Adwaita spreadsheet app for GNOME that uses Guile Scheme expressions instead of spreadsheet language. This is the ultimate marriage of the GNOME and GNU philosophies.
Every cell holds a GNU Guile expression. Double-click a cell and a real code
editor opens; whatever you write there is the cell. References like A1 are
ordinary variables, so a cell can say (+ A1 B1) — or (apply + (map (lambda (n) (* n n)) (iota 10))), or anything else Guile can do. Sales!B2 is one too, and
reads the cell on the tab called Sales.
Cellar is two programs. The shell — the window, the folder on disk, the tabs — is Haskell, built with GTK4 and libadwaita through haskell-gi, with the interface described in Blueprint. The kernel — the sheets and the evaluator — is GNU Guile. They talk over a pipe, in s-expressions. There is no C in this project.
Guile is where it was always meant to be: embedded in a host application as its extension language, which is what it was designed for.
"Cellar" is a play on spreadsheet cells and Lisp cons cells, as this application uses both.
nix develop # GHC + haskell-gi, Guile, GTK4, libadwaita, blueprint-compiler
make runmake run compiles the interface from Blueprint, builds the shell, and starts
it; the shell starts the kernel itself. make build builds the shell alone,
leaving the binary in .build/cellar.
With no workbook named it opens on a start screen: open a workbook, make one, or
take a scratch workbook, which Cellar gives a folder of its own out of the way.
Open one straight away
with make run FILE=example.cellar, or build a standalone wrapper with
nix build and run ./result/bin/cellar.
Run the test suites — they need no display — with make check. That is
make check-shell, which is Haskell and includes the shell driving a real
Guile kernel over a real pipe; make check-properties, which is the laws the
program rests on run against generated input, including the reference
arithmetic checked against the Guile copy of it; make check-window, which
hands events to the update and reads the state back, with the real kernel and
the real folder and no display; and make check-kernel, which is Guile.
Both make run and ./result/bin/cellar give you a window carrying the
desktop's fallback icon. That is expected: the icon appears only once Cellar is
installed somewhere the desktop already looks — nix profile install ., for
instance. See the note under Notes on haskell-gi.
| You type | You get |
|---|---|
42 |
42 |
(* 6 7) |
42 |
(+ A1 A2) |
the sum of two other cells |
(string-upcase "hello") |
HELLO |
(sum (range 'A1 'A10)) |
the sum of a rectangular range |
(+ Sales!B2 Sales!B3) |
two cells on the sheet called Sales |
(cell "Q1 2026" 'B2) |
a cell on a sheet whose name has a space |
(if (> A1 100) 'over 'under) |
a symbol |
(sort (list C1 C2 C3) <) |
a list — all of Guile is in scope |
(styled 42 #:background "red") |
42, on a red ground |
Bare references are bound automatically: any symbol in your code that looks like
a cell (A1, AA30) is bound to that cell's value before your expression runs.
A symbol with a sheet in front of it (Sales!B2) is bound the same way, to the
cell of that name on that tab; see Sheets that name each
other. Quoted data is untouched, so '(A1 B1) is
still a list of two symbols.
Where a helper needs the cell rather than its value, quote it: 'A1. That is
the one way to write a reference — a string is only ever text — so a reference
is always recognisable, and moving a row rewrites every one of them. A computed
reference has to arrive as a symbol too, by way of string->symbol.
Alongside all of (guile), cells get a few helpers:
(cell 'A1)— a cell's value, when you need to compute which cell(cell "Q1 2026" 'B2)— a cell on another sheet, named as a string(range 'A1 'B10)— a flat list of values over a rectangle(range "Q1 2026" 'B2 'B9)— the same, over a rectangle on another sheet(sum …),(product …),(average …)/(avg …),(count …),(cell-min …),(cell-max …)— these flatten their arguments and skip empty cells, so(sum (range 'A1 'A10))does the obvious thing(styled value #:color … #:background …)— the value, in colours of your choosing; see Colour
Errors stay local: a failing cell shows #ERR with the message in its tooltip,
and the rest of the sheet keeps working. Circular references are detected and
reported as the cycle they form, e.g. A1 -> B1 -> A1, or
Summary!C1 -> Sales!C1 -> Summary!C1 when the cycle goes round more than one
sheet.
A cell can say how it should be drawn:
(styled (* B2 C2) #:color "#c01c28" #:background "#fff3b0")Both keywords are optional, and a colour is either a hex literal (#rgb or
#rrggbb, with or without an alpha pair) or a colour name — "red", or
'red. Anything else is an error in that cell, with the offending value in the
tooltip, rather than a stylesheet quietly dropping it on the floor.
The style is part of the value, which has two consequences worth knowing. The
first is that conditional formatting is an ordinary if:
(if (> D8 500) (styled D8 #:color "#c01c28") D8)The second is that colour keeps itself: it is written in the cell, so it
survives a save, a reload and a reordering exactly as the expression does,
with nothing on the side to keep in step. It also stays out of everyone else's
way — a cell that refers to a styled cell sees the plain value, so (+ A1 B1)
and (sum (range 'A1 'A10)) do not care whether their operands are
coloured.
One thing to watch: a background on its own leaves the text in the theme's
colour, which under a dark theme is light. Pale fills want a #:color to go
with them.
Drag a row by the number in the gutter, or a column by its header, and drop it where you want it: the line you are holding dims, the one it would land on lights up. The edges of a header still resize the column, as they always did.
Ctrl+Shift with an arrow key does the same thing one place at a time, and the same four moves are in the main menu. Either way the active cell stays on the cell it was on, so you can hold the shortcut down and walk a row to where you want it.
A move rewrites the sheet, not just the screen. Every reference in every cell is
put through the same permutation as the cells themselves, so (* B2 C2) becomes
(* B3 C3) when its row slides down and a sheet means exactly what it meant
before it was rearranged. References are rewritten in the source text, so your
formatting, line breaks and comments come back untouched.
Ranges are handled as rectangles rather than as their two corners: when both
ends of a move are inside a range, the range keeps its extent and only the
contents shuffle — reordering the lines of a table does not change its
subtotal. When a row is moved out of a range it drops out of it, and a row moved
in is picked up, which is what a spreadsheet should do. A range whose corners
are computed rather than written down — (range 'A1 (corner-of my-table)) —
is the case this cannot see as a rectangle; each literal reference in it still
follows its own cell.
Right-click a row number in the gutter or a column header and take one of Insert Row Before, Insert Row After, Insert Column Before, Insert Column After, Delete Row or Delete Column. The right-click picks the line under the pointer before the menu opens, so what you point at is what you act on, and it stays selected afterwards so you can see what happened.
Ctrl+Alt with an arrow key does the same four things to the active cell, and so does the main menu. The sheet grows by a line each time; it never runs out of room at the bottom or the right the way a fixed grid would.
An insert shifts the cells below or right of it, and rewrites references exactly
as a move does — a sheet means after an insert what it meant before, and the row
that was (* B2 C2) still multiplies the same two cells once it has become
(* B3 C3). The active cell stays on the cell it was on, so opening a row above
it carries it down.
Ranges are rectangles here too, and here the rectangle grows: a row opened
inside (sum (range 'A1 'A3)) makes it (sum (range 'A1 'A4)), so whatever you
write in the new row is taken into the subtotal. A row opened above the range
pushes the whole range down instead, and one opened below it leaves it alone.
A sheet grows to fit what is read into it, so a file saved after an insert opens at the size it was saved at rather than being trimmed back to the default 100×26.
Drag across the cells to take a block of them, or hold Shift and use the arrow keys. Click a row number or a column heading to take that whole line, and Shift-click another to take every line between the two. The block is drawn in a faint wash, and the cell an edit would go into keeps its outline.
With a block taken, Ctrl+= puts a sum after it, and the menus offer Sum, Average, Count, Minimum, Maximum and Product. Where the formula goes follows the shape of the block: a column of cells is totalled underneath it, a row of cells beside it, and a block wider and taller than one gets a formula per column in the row below.
What lands in the cell is (sum (range 'A1 'A4)), an ordinary literal range,
so it moves with its cells when a row is moved, inserted or deleted like any
other reference. A block of one cell is named on its own: (average 'A1).
Dragging a row number or a column heading still reorders, as it always did. Selecting several of them is the Shift-click, not a drag.
Ctrl+- deletes the row the active cell is on and Ctrl+Alt+- deletes its column, and both are in the menus beside the inserts. The cells on the line go with it, their files are removed from the folder, and everything below or right of it moves up or left.
Deleting is the one rearrangement with nowhere to send some of what points at
it, and Cellar says so rather than guessing. A reference written on its own —
the A3 in (+ A3 1) — becomes %deleted when row 3 goes, and the cell
holding it reads this cell refers to a cell that was deleted. Letting it mean
whatever slid up into row 3 would change what the cell computes without a word,
which is the failure mode a spreadsheet is least able to show you.
A corner of a written range is different, because a range is a rectangle and a
rectangle with a missing corner is not one. Those follow whatever took the
line's place, so (sum (range 'A1 'A4)) with row 2 deleted becomes (sum (range 'A1 'A3)) and totals the three rows that are left. A range entirely below the
deleted line moves up whole, and one entirely above it is untouched.
The last row of a sheet cannot go, and neither can the last column: a sheet with no cells has nothing to draw and nothing to hold. Both halves refuse it, the window with a message and the kernel with an error, so a delete that arrives any other way is refused too.
Cellar runs as two programs. The kernel holds the sheets and evaluates cells; the shell holds the window, the folder on disk and the tabs. They talk over a pipe.
The reason is that a cell is an arbitrary Guile expression, so a cell can be
(let loop () (loop)). While the evaluator lived inside the application that
meant the window stopped and stayed stopped — evaluation ran inside the paint,
and there was nowhere to catch it from. Now it means one process stops and the
one holding the window carries on drawing, notices after ten seconds that it has
been waiting, and offers to stop it. Stopping kills the kernel and hands the
sheets to a new one; the edit that caused it was never written to disk, so
stopping loses nothing but the edit itself.
The paint path does no talking at all. The kernel does not answer questions about single cells — it sends a snapshot of the whole sheet, already rendered: the string for each cell, which way to align it, its colours, and its error message if it has one. The grid draws that and nothing else, so scrolling, resizing and uncovering the window are free, and the last good snapshot survives its kernel.
shell -> kernel (request 12 set-cell "Summary" "D6" "(sum (range 'D2 'D4))")
kernel -> shell (reply 12 ((sheet . "Summary") (rows . 100) (columns . 26)
(cells ("D6" "580.93" #t #f #f #f))
(others ((sheet . "Sales") …))))
A sheet is named to the kernel by the name on its tab, because that is the name
cells use: a cell that says Summary!B2 is asking for a sheet by name. Renaming
a tab is therefore a request of its own. An answer about one sheet carries the
rest of the book under others, because an edit to one sheet changes what the
cells reading it come to, and the shell has no evaluator to work that out.
Messages are s-expressions with a byte count in front, which is what lets the shell read without ever blocking: it looks at what has arrived, decides whether a whole message is there, and goes back to drawing if it is not.
The split is along a line that was already there. The model never knew about GTK and the store never knew about evaluation, so what moved was mostly the boundary being made explicit: the kernel is the model and its sandbox — the part where evaluation and the dependency graph are mutually recursive, and which therefore cannot be split further — and the shell is everything else, including the store. Reference arithmetic is the only thing written on both sides.
Doing it as one process split first, in Guile on both sides, is what made the change of language small. The wire format was working and tested before a line of Haskell existed, so replacing the shell meant writing against a contract rather than discovering one — and the four GUI suites, which drive the real window and do not know what is behind it, carried over untouched.
A .cellar folder is a workbook: several spreadsheets in one folder, and so
several spreadsheets in one Git repository. Each is a tab, and each is a folder
of its own under sheets/.
Add Sheet (Ctrl+T) makes one, Rename Sheet (Ctrl+Shift+R) renames the folder along with the tab, and dragging a tab reorders them. Which tab you were on is remembered, so reopening a workbook comes back to the sheet you left.
Closing a tab is deleting the sheet, because a tab is a sheet rather than a
view of one — so it asks first, and refuses when it would leave the workbook
with nothing in it. What it deletes is only what Cellar wrote: a README you
left in a sheet's folder keeps the folder standing, empty of a sheet and so no
longer a tab, rather than being taken down with it.
A cell on one sheet can read a cell on another. The name it uses is the name on the tab:
(+ Sales!B2 Sales!B3)That is the whole of it when the sheet's name is one word. A name with a space in it, or a bracket, or anything else the Guile reader stops at, cannot be written that way. Write the reference as the symbol it is:
#{Q1 2026!B2}##{…}# is Guile's own syntax for a symbol with awkward characters in it, so
the reference stays an ordinary variable and reads back as one. The friendlier
spelling for the same cell puts the sheet in a string and the cell in a symbol,
which works for every name:
(cell "Q1 2026" 'B2)
(sum (range "Q1 2026" 'B2 'B9))A reference to another sheet follows the cell it names, exactly as one to this
sheet does. Move a row on Sales and every Sales!B2 in the workbook moves with
it, wherever it is written. Rename Sales and every reference to it is rewritten,
into #{…}# form if the new name needs it. Delete a sheet and the cells that
named it show #ERR and say which sheet has gone.
A cycle can go round several sheets now, and is still caught. The message names
the sheet of each cell in it: Summary!C1 -> Sales!C1 -> Summary!C1.
There is one limit. A cross-sheet reference has to be written out to be found.
(cell some-name 'B2), where some-name is worked out as the cell runs, reads
the right cell, but nothing rewrites it when the sheet it names is rearranged.
That is the same bargain (range 'A1 'B10) has always made, and the reason a
reference is a quoted symbol and a sheet name is a string: the rewriting has to
tell the two apart on sight.
| Key | Action |
|---|---|
| Arrows, Tab, Page Up/Down, Home/End | Move the active cell |
| Double-click, Enter, or Ctrl+E | Edit the active cell in Cellar |
| Ctrl+Shift+E | Open the active cell in your text editor |
| Ctrl+Return | Apply, while in the editor |
| Delete | Clear the active cell |
| Ctrl+Shift+Up/Down | Move the active row |
| Ctrl+Shift+Left/Right | Move the active column |
| Ctrl+Alt+Up/Down | Insert a row before/after |
| Ctrl+Alt+Left/Right | Insert a column before/after |
| Ctrl+- | Delete the active row |
| Ctrl+Alt+- | Delete the active column |
| Shift with an arrow key | Take a block of cells |
| Ctrl+= | Sum the block |
| Ctrl+R | Recalculate |
| Ctrl+T | Add a sheet to this workbook |
| Ctrl+Shift+R | Rename the sheet showing |
| Ctrl+W | Delete the sheet showing |
| Ctrl+Page Up/Page Down | Move to the sheet before/after |
| Ctrl+N / Ctrl+Shift+N | New workbook / New scratch workbook |
| Ctrl+O | Open a workbook folder |
| Ctrl+Shift+S | Copy this workbook elsewhere |
| Ctrl+, | Preferences |
| Ctrl+Q | Quit |
There is no Save. A cell is written to its own file the moment you apply the edit, so the folder on disk is the sheet rather than a copy of it taken when you last remembered to ask. Clearing a cell deletes its file; moving a row renames the files it moved; dragging a column wider records the width. Ctrl+S is bound only to say so.
That falls out of the format. A sheet was already a folder of one file per
cell, and a cell already held nothing but its source text, so there was never
much reason for an edit to sit in memory waiting to be flushed — least of all
when the whole point of the layout is that git diff should tell you what
changed.
The shell owns the sources and the kernel owns what they come to. An edit is sent to the kernel, and the file is written when the kernel says what it kept — so what lands on disk is what the sheet actually holds rather than the shell's guess at it.
The traffic goes the other way too. Cellar watches the whole workbook — every
sheet's cells, every sheet's primary file, and the folder they sit in — and
anything that changes a cell's file changes the cell: your editor, a
git checkout, a script, another copy of Cellar. The grid reloads and the
sheet recomputes, and a toast says why the numbers moved. A git checkout that
brings a sheet in or takes one away rebuilds the tabs.
The one thing worth knowing is that this makes an edit immediate and permanent
in the same breath. There is no undo, and never was; what there is instead is
the git init checkbox on the New Workbook dialog, which is the honest way to
get one for a folder of text files.
Copy To… (Ctrl+Shift+S) is what is left of Save As: it writes every sheet of
the workbook to a new folder and carries you on editing there, leaving the
folder you came from as it stands. A scratch workbook (Ctrl+Shift+N) is an
ordinary workbook in a folder Cellar picks, under
~/.local/share/cellar/scratch/, so that starting one asks you nothing; Copy To
is how it becomes a workbook you keep.
The cell bar has two buttons. The pencil (Enter, or Ctrl+E) opens the cell in
Cellar's own editor. The folder beside it (Ctrl+Shift+E) opens the cell's
file — cells/B2.scm, the very file the sheet is made of — in another program,
which is whatever your desktop opens text files with: Text Editor on a stock
GNOME. There is no preference to set first, and nothing to switch between: the
two buttons are the two editors.
Saving there is saving the cell. Cellar neither waits for the program to exit
nor reads anything back from it — it watches the sheet folder instead — so
code and gedit want no --wait and emacsclient is happier with -n.
Leave the cell open in a buffer all afternoon and save whenever you like; each
save lands in the sheet, with the program still open. Several cells can be open
in several editors at once.
An empty cell has no file until you open it: saveCell takes the file away
when a cell is cleared, and no program can be handed a path that is not there.
So opening an empty cell writes an empty file for the other program to open,
and if you write nothing to it, the next save of that cell removes it again.
Under Preferences (Ctrl+,) there is one row: a Command. Name one and Ctrl+Shift+E runs that instead of asking the desktop. This is for the editors a desktop cannot express — a terminal one, or a running Emacs.
%s in the command is where the file name goes. Without one it is added at the
end, which is what most graphical editors want:
| Command | What it opens |
|---|---|
| (empty) | whatever your desktop opens text files with |
gnome-text-editor |
Text Editor, with the file as its argument |
code |
VS Code |
emacsclient -n |
a frame on a running Emacs |
xterm -e vim %s |
vim, in a terminal of its own |
One thing to watch for: a terminal editor needs a terminal. vim on its own has
nowhere to draw, so wrap it as above.
The command is saved in ~/.config/cellar/config.scm. CELLAR_EDITOR
overrides it for one run — set it to a command to force that command, or to the
empty string to force the desktop's own choice — and the preferences dialog
says so when it is set. If the command cannot be started at all, Cellar says so
in a toast; the pencil is still there, and it never depended on any of this.
Older config files carry an external-editor-enabled flag from when one button
served both editors and a switch said which it meant. It is read straight past,
the command beside it still stands, and the next save drops it.
GTK4 ships no spreadsheet widget, and this project did not write one in C. The
grid is a GtkColumnView: one GtkColumnViewColumn per spreadsheet column,
each with a GtkSignalListItemFactory whose setup and bind callbacks close
over a one-element box holding that column's index — a box rather than the
number itself, because inserting a column renumbers every column to its right
and there is nowhere to tell a callback that was installed once.
The interesting part is that the list model holds no data. It is a
GtkStringList of row numbers whose only job is to give the view the right row
count; at bind time each cell asks gtk_column_view_cell_get_position() for its
row, combines that with the column index from its closure, and looks the value
up in a Scheme hash table. That avoids the one thing that would have been
genuinely painful from a dynamic language — defining a custom GObject item
class for the model — and it means only the visible rows are ever realised.
A 100×26 sheet costs a few hundred widgets instead of 2,600, and it would scale
to 10,000 rows unchanged.
Double-click detection is a GtkGestureClick added to each cell's label in
setup (not bind, which would leak a controller on every scroll); the handler
filters on n_press = 2.
Colour goes through the stylesheet rather than through the widgets. Each
distinct (colour, background) pair a sheet asks for earns a generated CSS class
in a provider of the grid's own, added to the display above the application's;
a cell wears at most one of those classes at a time. The classes then outlive
the cell widgets GtkColumnView recycles underneath them, and the palette
stays as small as the sheet's actual use of colour.
Dragging works the same way round. GtkColumnView can reorder its own columns,
but that moves the view's columns and not the sheet behind them — the letters
would come out in the wrong order and A1 would no longer be the cell in the
corner — so the view's reordering stays off and the drag is a GtkGestureDrag
on the gutter cell and on the column header, ending in the same move-row! and
move-column! the keyboard uses. The row under the pointer is whatever
gtk_widget_pick finds there, matched against the cells the factories handed
us; the column is found by measuring the header widgets.
flake.nix dev shell (GHC + haskell-gi, Guile) and both packages
nix/cellar.nix the shell: a Haskell binary, wrapped with its typelibs
nix/kernel.nix the kernel: Guile, compiled ahead of time, no GTK at all
cellar.cabal the shell's dependencies
the shell — Haskell, everything that is not evaluation
hs/Main.hs the entry point
hs/Cellar/App.hs the application: what starts, and what the keys do
hs/Cellar/App/Types.hs the window's state, and what everything asks of it
hs/Cellar/App/Kernel.hs talking to the kernel, and the stall watchdog
hs/Cellar/App/Workbook.hs tabs, saving, and catching up with the folder
hs/Cellar/App/Dialogs.hs the dialogs, and the signals that raise them
hs/Cellar/Grid.hs the GtkColumnView spreadsheet, drawn from snapshots
hs/Cellar/Editor.hs the code editor and its live result preview
hs/Cellar/Client.hs starting the kernel and talking to it without blocking
hs/Cellar/Store.hs workbooks and sheets on disk — no GTK, no evaluator
hs/Cellar/View.hs a sheet as the window sees it: strings, colours, alignment
hs/Cellar/Watch.hs noticing that the folder changed under us
hs/Cellar/Config.hs preferences, and the command parsing behind them
hs/Cellar/External.hs handing a cell to an editor of your own
hs/Cellar/Protocol.hs the framing: a byte count, a newline, a datum
hs/Cellar/Sexp.hs an s-expression reader and writer, protocol-sized
hs/Cellar/Ref.hs cell references, and where they land when a row moves
the kernel — Guile, the sheets and nothing else
bin/cellar-kernel.scm the loop: read a request, write a reply
src/cellar/kernel.scm the requests it serves and the snapshots it answers with
src/cellar/model.scm the sheet: sources, evaluation, the sandbox
src/cellar/protocol.scm the same framing, from the other side
src/cellar/ref.scm the same reference arithmetic, from the other side
the interface, and the tests
ui/cellar.blp main window: header bar, tab bar, cell bar, column view
ui/editor.blp the cell editor dialog (AdwDialog + GtkSourceView)
ui/preferences.blp the preferences dialog (AdwPreferencesDialog)
data/dev.enzuru.Cellar.desktop the desktop entry; the file name is the application id
data/icons/hicolor/ the application icon, full colour and symbolic
test/Spec.hs the shell, headless, including a real kernel on a real pipe
tests/model-test.scm the kernel's model, headless
tests/kernel-test.scm the kernel's protocol, over a real pipe
tests/gui-smoke.sh drives the real UI under Xvfb (`make smoke`)
tests/gui-start-smoke.sh the start screen, making a workbook, saving it
tests/gui-tabs-smoke.sh tabs, adding sheets, the format-1 migration
tests/gui-kernel-smoke.sh a cell that will not finish, and surviving it
tests/gui-drag-smoke.sh dragging a row and a column, checked on disk
tests/gui-editor-smoke.sh an external editor, and the preference that names it
tests/workbook.sh fixtures: workbooks written out as the format documents them
The five Cellar.App.* modules depend on each other in one direction only --
Types, then Kernel, then Workbook, then Dialogs, then the application itself --
so there is a place each thing goes and no cycles to break.
Cellar.Ref and src/cellar/ref.scm are the same module written twice, and
have to be: they are the vocabulary the two halves share. Nothing else is
duplicated — the kernel has no idea what a file is, and the shell has no
evaluator.
src/cellar/model.scm, src/cellar/store.scm and src/cellar/config.scm
deliberately have no GTK dependency, which is why the test suites can run
without a display.
A sheet is a folder, not a file. Every cell that holds anything is one small
file of Guile source under cells/, named for the cell, and a primary file at
the top holds what is true of the sheet rather than of any one cell.
A workbook is a folder of those, and the folder a repository is made of:
budget.cellar/
workbook.scm
sheets/
Summary/
sheet.scm
cells/
A1.scm "Qty"
A2.scm 7
D6.scm (sum (range 'D2 'D4))
Q1/
sheet.scm
cells/
.git/
;; A Cellar workbook. Each sheet is a folder under sheets/.
((format . 2)
(sheets
"Summary"
"Q1")
(active . "Summary"));; A Cellar sheet. The cells are in cells/, one file each.
((format . 1)
(rows . 102)
(columns . 28)
(widths (1 . 181)))An entry to a line in both, so that adding a sheet, renaming one or dragging a tab is a one-line diff rather than a rewritten file.
The index is a hint and the disk is the truth: which sheets exist is decided by
which folders are there, and workbook.scm decides only what order the tabs
come in. A sheet that arrives in somebody else's commit turns up as a tab rather
than being ignored, and one a git checkout takes away leaves rather than being
a tab over a folder that is not there. That is the most a file two people can
edit at once should be trusted for.
A workbook from before tabs has its sheet.scm and cells/ at the top of the
folder and no workbook.scm above them. Cellar reads one where it lies — as
a workbook of one sheet, in a tab named for the folder — and moves it into
sheets/ only when you add a second sheet and give it a reason to. The folder is
renamed rather than copied, so Git sees a rename and git log --follow still
walks back through a cell's history. Rearranging somebody's repository on the way
to merely opening it would be a rude way to say hello.
The point of it is version control. A cell already holds source text, so giving
each one a file makes an edit to a cell a one-line diff, a cell's history
git log -p sheets/Summary/cells/D6.scm, and two people editing different
corners of a sheet a merge rather than a conflict. New Workbook offers to
git init the folder for you, ticked by default — around the workbook rather
than around any one sheet, which is the whole reason a workbook exists; nothing
here commits on your behalf after that, and the repository is yours to manage.
The cost is that a cell's name is its position, so inserting a row renames every file below it and rewrites every reference to them. That is a loud diff, but an honest one: the sheet really did change shape, and the references really did all change with it.
Only files named exactly as a cell would be — A1.scm, AA30.scm — are read as
cells, and only those are ever written or deleted. A README.md beside them, or
a helpers.scm in cells/, is yours and is left alone — including by the
watcher, which reads the folder but only ever finds cells in it. A workbook can
be opened by its folder, by its workbook.scm, or by the sheet.scm of any
sheet inside it.
Because the folder is the sheet rather than a rendering of it, editing these files by hand is a supported way to use Cellar and not a way to corrupt it: see Saving, which there is none of.
Things worth knowing if you extend this:
- Depend on
gi-gtk4andgi-gdk4, not thegi-gtk/gi-gdkshims.gi-adwaitais built against the former, and a widget from one is not a widget from the other as far as the type checker is concerned. Naming both puts two modules calledGI.Gtkon the search path and every import becomes ambiguous. - The
GtkSignalListItemFactorycallbacks hand over a plainGObject.Object. For a column view what it actually is, since GTK 4.12, is aGtkColumnViewCell— the thing that knows which row it has been recycled onto — sounsafeCastTo Gtk.ColumnViewCellit and usecolumnViewCellGetPosition. columnViewScrollTotakes twoMaybearguments whose types cannot be inferred fromNothingalone; annotate them (Nothing :: Maybe Gtk.ColumnViewColumn) or the constraint solver has nothing to go on.- Several GTK functions that look like they take
TexttakeFilePathinstead —builderNewFromFile,fileNewForPath,iconThemeAddSearchPath— because the C API takes a filename rather than a string. - Watch for functions that take ownership.
gtk_widget_add_controllerandgtk_no_selection_newboth do, so haskell-gi disowns the value you passed them; anything that reaches back for it afterwards -- a gesture callinggestureSetStatefrom its own callback, or the grid appending a row to the list model it handed the selection -- is reading a pointer you no longer hold. haskell-gi reports it at runtime as "accessing a disowned pointer, this may lead to crashes", and it works right up until the widget lets go. Take a reference of your own withobjectReffirst. widgetTranslateCoordinatesis deprecated as of GTK 4.12 in favour ofwidgetComputePoint, which takes and returns agraphene_point_t. The Guile version had to use the deprecated call because G-Golf marshals neither; here the modern one is available and is what the grid uses.- Handlers named in a
.blp/.uifile are still not used here: the objects get anidand are connected to from Haskell, which keeps the wiring in one place and out of the markup. - Keyboard handling on a
GtkColumnViewmust use the capture phase ((set-propagation-phase controller 'capture)). The view binds the arrow keys for its own row navigation and will swallow them in the bubble phase. - The window icon is not the application's to set, and there is no way to make
an uninstalled build show one.
gtk_window_set_icon_nameis an X11-only call that Wayland ignores; there the compositor matches the toplevel's application id against an installed<id>.desktopand reads itsIcon=key. Since that lookup happens in the compositor's process, nothing the app does reaches it:install-icons' search path only feeds the app's ownGtkIconTheme(which is why the about dialog is fine), and the wrapper'sXDG_DATA_DIRSonly covers the app's process, not GNOME Shell's. GTK 4.22 does implementxdg-toplevel-icon-v1, which would let a client hand the compositor rendered pixels instead, but mutter 50 does not implement the other half, so the global is never advertised. Installing the desktop entry into a prefix the session already searches is the only thing that works. - A gesture on a column header has to be in the capture phase. The header
is a
GtkColumnViewTitlewith gestures of GTK's own, and one of them claims the sequence as soon as the pointer moves: a bubble-phase gesture there sees the button press and then nothing else, which looks exactly like a drag that silently does not work. - The header widgets are not the column's to hand out — a
GtkColumnViewColumnhas a title string, not a header factory — so they are reached by walking the view: its first child is the header row, whose children are the titles in column order. They exist as soon as the columns are appended, before the view is realised. - GObject identity survives the round trip: the same GObject always comes back
as the same GOOPS instance, so
eq?is a reliable way to recognise a widget handed back bygtk_widget_pick. That is what lets a drag find the cell under the pointer without any coordinate arithmetic. - Packaging note: a wrapper must set
GI_TYPELIB_PATH, not prepend to it. Inheriting a host path that points at a different glib makes GTK abort ing_binding_class_initat startup. Also note that glib's and pango's typelibs live in theiroutoutput, while${glib}refers tobin.
Cellar is free software. You can redistribute it and modify it under the terms
of the GNU General Public License as published by the Free Software Foundation,
either version 3 of the License, or (at your option) any later version. The
text of version 3 is in LICENSE.
Both halves are covered. The Haskell shell and the Guile kernel are one program that happens to run in two processes.
Everything Cellar is built from allows this. GTK 4, libadwaita and Guile are
under the LGPL, and so are the haskell-gi bindings that reach them. The
pipes, vector and hedgehog packages are under the BSD 3-clause licence.
gi-gtk4-declarative, which is compiled from the checkout next door rather
than fetched as a package, is under the Mozilla Public License 2.0, and none of
its source files carries the notice that would keep it out of a GPL work, so
section 3.3 of that licence allows it.
There is no GPL header at the top of each source file. Those files open with a
paragraph about what the module is for, which is what they are read for, and a
notice repeated sixty times would bury it. The grant above is the one that says
or any later version, since LICENSE carries the text of version 3 and says
nothing about later ones. The licence is also declared in cellar.cabal, in
each Nix derivation, and in the About dialog, which has said
GTK_LICENSE_GPL_3_0 all along; that is GTK's name for version 3 or later, and
it now agrees with the rest.
The grid, the editor, evaluation, recalculation, error display, keyboard
navigation, row and column reordering, and the packaged nix build were all
exercised end-to-end against a real GTK build, most of it by make smoke, which
also confirms that the application icon resolves by application id and renders
in the about dialog. Reordering was driven the same way, against
example.cellar, by keyboard and by mouse: rows and columns move, the active
cell follows, the subtotal and tax hold their values across the move, a move at
the edge of the sheet says so in a toast, and dragging the edge of a header
still resizes the column instead of moving it. The same run colours a cell from
the editor, in a colour the palette has never seen, which is the case that
reloads the grid's CSS provider while the grid is on screen.
The split is covered headlessly and through the window, and now from both
sides. make check-shell runs the Haskell end: references round-tripping,
the s-expression reader against everything the writer emits, the framing fed a
byte at a time — the worst a pipe can do — the store's whole folder format
including the format-1 migration, and then the client driving a real Guile
kernel over a real pipe. That last one is the test the port turns on: two
languages agreeing about a wire format, with a cell whose text is full of the
quotes, backslashes and parentheses the messages are themselves made of. The
same run opens two sheets, has one read the other across the wire, and watches
an edit to one come back as an answer about both.
make check-kernel runs the Guile end: the model, and the same protocol from
the other side — sheets opening at the
size they were given, one sheet reading another in all three spellings, an edit recomputing what depends on it, the source echoed
back as the model kept it, an error rendered and flagged, a colour surviving the
trip, a preview evaluated without being kept, a move reporting the sources it
rewrote with the subtotal unchanged, a move and a rename rewriting the
references on the sheets that were only watching, a cycle that goes round two
sheets naming both of them, and every way of asking for something impossible
refused without the kernel falling over. The client's own side of
that is in the Haskell suite, including the part that matters: a cell that will
not finish, fifty pumps over it that all return, and a restart that abandons
what was outstanding and comes back knowing nothing.
tests/gui-drag-smoke.sh drags a row by its number and a column by its
heading, and reads the folder afterwards rather than the screen: the dragged
line has to land where it was dropped, the lines it passed have to slide over,
and the reference in another cell has to have followed the cell it names. It
opens no dialogs, deliberately -- gui-smoke.sh has drag steps too, but they
come after the editor steps, and where the editor dialog does not take its
keystrokes it stays open and swallows every click after it, so a pass there
proves nothing about dragging.
Deleting a row or a column is covered at every level it passes through, because
the interesting part of it is what happens to the references that named the line
and those are rewritten in a third place again. The Guile model suite deletes a
row and checks that the cells on it are gone, that what was below has come up,
that a reference to the deleted line is written %deleted and reads as an
error, and that a range it was taken out of has shrunk by one rather than lost a
corner; it also checks that the last column of a sheet refuses to go. The
protocol suite asks the kernel for the same deletes over the pipe and reads the
sources it sends back. The Haskell suite checks the same arithmetic on its own
side and the grid's half of it: the columns that are left keep the names their
widths hang on, and the active cell steps back when the line it was on was the
last. The window suite does it end to end — a row deleted, the cell that was
below it now on top, the cell file of the deleted row removed from the folder —
and tests/gui-menu-smoke.sh presses Ctrl+- and Ctrl+Alt+- in the real
application and reads the sheet file afterwards, which is what caught the first
spelling of the column shortcut: <Control><Shift>minus parses and then never
fires, because under Shift the keyval is underscore.
tests/gui-kernel-smoke.sh then plants (let loop () (loop)) in a workbook and
opens it. The window survives, still draws, still opens dialogs, and still
answers the mouse while the kernel spins; the kernel is confirmed to be the part
that is stuck; and when the shell is killed the orphaned kernel notices and
exits rather than spinning on a core forever. That last one needed a real fix:
an orphan is adopted by the session's subreaper rather than by init, so the
kernel watches for its parent changing rather than for getppid returning 1,
and it checks on an alarm because a spinning process never gets back to its pipe
to check anything else.
The folder format is covered twice over. The Haskell suite writes real
directories in a temporary place and reads them back — with no evaluator in
sight, since the store deals in cell names and source text: the round trip, a
cleared cell losing its file, and a README.md or a stray helpers.scm in
cells/ surviving a save untouched. The workbook layer is covered the same
way: adding, renaming,
reordering and removing sheets; a duplicate name refused, including one
differing only in case; a name a folder cannot have refused; a note left in a
sheet keeping its folder standing when the sheet is deleted; the last sheet
refusing to be deleted at all; a sheet arriving on disk that the index never
heard of turning up anyway, and one whose folder went being dropped; and a
workbook written before there were tabs reading as one sheet where it lies, then
moving under sheets/ — cells, column widths and all — the moment a second
sheet is added. tests/gui-tabs-smoke.sh drives the tabs through the real
application and reads the workbook folder off disk: a workbook opening on the
sheet it was left on, Ctrl+Page Up/Down moving between sheets and each move
being written down, an edit landing in the sheet that is showing and in no
other, Ctrl+T adding a sheet with a folder and a primary file of its own, a
sheet planted in the folder from outside turning up as a tab the keyboard can
reach, and a workbook in the older format opening untouched and then being moved
under sheets/ when a second sheet is added. Deleting a sheet was driven the same way
by hand: the confirmation, the folder going, the index catching up, and the last
sheet refusing with a toast. tests/gui-start-smoke.sh then drives the start
screen through the application and reads the resulting folder off disk rather
than photographing it — though see the note at the head of that file: from its third
step on it does not currently drive every machine, for reasons that predate
per-cell saving and are the harness's rather than the application's.
Saving as you go, and catching up with the disk, are covered by make smoke.
Its assertions are read off the sheet folder without anything ever having been
saved: the cell edited in step 4 is in its own file, the reordering moved the
files it moved, the inserted rows grew the primary file, and the widened column
was remembered. The last step names a stand-in editor in the preferences
dialog, opens a cell with Ctrl+Shift+E so that editor writes the cell's own
file, and then makes Cellar rewrite the sheet from memory — if the watcher had
missed the edit, that would overwrite it, so the expression surviving is the
proof that the reload happened.
The rest was driven by hand against a real GTK build: an edit landing on disk with no save; clearing a cell deleting its file; a cell file changed from outside reaching the grid, along with a cell file created from outside; an editor left open and saving twice, each save arriving while it still ran; a scratch workbook getting its own folder under the data directory and autosaving from the first keystroke; Copy To writing a new folder and carrying on there; and Ctrl+S saying what it now says. Cellar's own writes were checked not to come back as reloads — with the file watcher traced, seventeen of eighteen wake-ups found the disk already saying what the model said, and the one that did not was the external editor's.
The external editor is covered by the last step of make smoke, which names a
stand-in editor in the preferences dialog and opens a cell with it — the
stand-in rewrites the file and exits; the cell, everything computed from it, and the file
the next save writes all come back changed. Two paths that step does not cover
were driven by hand the same way: CELLAR_EDITOR overriding the saved
preference, and a command that does not exist, which reports itself in a toast
— the pencil is still there, so there is nothing to fall back to. make check covers the rest headlessly —
command splitting, %s substitution, and the preferences surviving a restart.
Choosing a folder is the exception. Both Open Workbook… and the location
button in the New Workbook dialog hand off to the desktop portal, whose window cannot be
scripted from a test harness; each link there was checked separately (the async
callback fires and returns a GFile, and get-path yields a string). That is
why the New Workbook dialog fills its location in for you rather than making you
pick one, and why a sheet named on the command line opens without a dialog at
all: both paths stay testable.