Skip to content

Latest commit

 

History

35 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Cellar

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.

Name

"Cellar" is a play on spreadsheet cells and Lisp cons cells, as this application uses both.

Running it

nix develop      # GHC + haskell-gi, Guile, GTK4, libadwaita, blueprint-compiler
make run

make 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.

Writing cells

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.

Colour

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.

Reordering rows and columns

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.

Adding rows and columns

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.

A block of cells, and a formula under it

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.

Taking rows and columns away

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.

Two processes

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.

Sheets and tabs

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.

Sheets that name each other

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.

Keyboard

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

Saving, which there is none of

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.

Using your own editor

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.

When the desktop's choice is not yours

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.

How the grid works

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.

Layout

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.

File format

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.

Workbooks written before there were tabs

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.

Notes on haskell-gi

Things worth knowing if you extend this:

  • Depend on gi-gtk4 and gi-gdk4, not the gi-gtk / gi-gdk shims. gi-adwaita is 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 called GI.Gtk on the search path and every import becomes ambiguous.
  • The GtkSignalListItemFactory callbacks hand over a plain GObject.Object. For a column view what it actually is, since GTK 4.12, is a GtkColumnViewCell — the thing that knows which row it has been recycled onto — so unsafeCastTo Gtk.ColumnViewCell it and use columnViewCellGetPosition.
  • columnViewScrollTo takes two Maybe arguments whose types cannot be inferred from Nothing alone; annotate them (Nothing :: Maybe Gtk.ColumnViewColumn) or the constraint solver has nothing to go on.
  • Several GTK functions that look like they take Text take FilePath instead — builderNewFromFile, fileNewForPath, iconThemeAddSearchPath — because the C API takes a filename rather than a string.
  • Watch for functions that take ownership. gtk_widget_add_controller and gtk_no_selection_new both do, so haskell-gi disowns the value you passed them; anything that reaches back for it afterwards -- a gesture calling gestureSetState from 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 with objectRef first.
  • widgetTranslateCoordinates is deprecated as of GTK 4.12 in favour of widgetComputePoint, which takes and returns a graphene_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/.ui file are still not used here: the objects get an id and are connected to from Haskell, which keeps the wiring in one place and out of the markup.
  • Keyboard handling on a GtkColumnView must 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_name is an X11-only call that Wayland ignores; there the compositor matches the toplevel's application id against an installed <id>.desktop and reads its Icon= 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 own GtkIconTheme (which is why the about dialog is fine), and the wrapper's XDG_DATA_DIRS only covers the app's process, not GNOME Shell's. GTK 4.22 does implement xdg-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 GtkColumnViewTitle with 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 GtkColumnViewColumn has 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 by gtk_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 in g_binding_class_init at startup. Also note that glib's and pango's typelibs live in their out output, while ${glib} refers to bin.

Licence

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.

What has been verified

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.

About

A native GNOME Adwaita spreadsheet app written in Haskell that uses Guile Scheme instead of spreadsheet expressions

Resources

Stars

4 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages