Manual

Everything EEditor does, in the order you're likely to need it.

1. The workspace

A workspace is an ordinary folder. EEditor shows what's in it, edits the Markdown files it finds, and writes nothing else there except one hidden folder, .eeditor/, for your keybindings.

On first launch EEditor picks a default folder and opens it. To use a different one, choose ⋯ → Open another folder… above the file tree and pick any directory — a git repository, a synced folder, an external disk. The choice survives restarts.

Several windows

Each window is one workspace. File → New Window… (⌃⌘N) or ⋯ → Open a folder in a new window… opens another folder beside the first, with its own notes, agenda, database and keybindings — one for work and one for testing, say. Opening a folder that already has a window brings that window forward. The windows open at the next launch are the ones open now; closing one closes just that one, and quitting saves them all.

Because the workspace is just a folder, everything you already do with folders keeps working: back it up, version it, sync it, or open the same notes in another editor. EEditor takes no lock and keeps no separate index that could drift from the files.

What EEditor stores beside your notes: the agenda database (items, categories, rules) and any tables you create in EELisp live in one SQLite file, .eeditor/eeditor.db in the workspace, next to your keybindings. Your notes are never inside it — they stay Markdown files you can read without EEditor.

2. Files and folders

The file tree is the left column. Click a file to open it. The two buttons at the top create a new file and a new folder at the root; to create inside a folder, right-click it (long-press on a touch screen) and use the menu, which also offers Rename… and Delete.

Renaming and deleting keep open tabs honest: a renamed file follows its tab, and deleting a file or folder closes the tabs underneath it and moves you to a neighbour rather than leaving a dead editor.

Where a file actually is

The tree names files by where they sit in the workspace, which is no use the moment you want to hand one to something else. The same right-click menu offers Show location… — the full path on disk, selectable, with a Copy button — and Reveal in Finder, which opens Finder with the file selected. Right-clicking the workspace row itself locates the workspace folder.

Editor tabs have the same two items on right-click. That is the only way to ask about a file opened in place from outside the workspace: it is marked ↗ and never appears in the tree.

Files beginning with a dot are hidden from the tree, which is why your keybindings live in .eeditor/keybindings.eelisp — present, editable, out of the way.

The daily note

⌘D opens today's note, YYYY-MM-DD.md at the root of the workspace, creating it with a date heading if it doesn't exist. It never overwrites an existing one, so pressing it repeatedly through the day just takes you back to the same note.

What a new one starts with can be yours: a (defn daily-template (day) …) in the keybindings' (on-load …) returns its text — or, with the PIM, its pim-daily-template does, with today's agenda filled in. See a daily note that starts with your lists.

3. The editor

The editor is CodeMirror 6 with Markdown syntax highlighting. Edits save automatically about a second after you stop typing; ⌘S saves immediately. Nothing unsaved survives as unsaved: closing a tab, switching to another one, closing the window and quitting all write what is in the buffer first.

Markdown is the default, not the limit — any text file opens: source code, a csv, a log, a config file, a file with no extension at all. Only binary formats (images, PDFs, archives) are left out of the file tree — which is why a note's assets folder looks empty there, even though its images are in it.

Tab

Tab indents and ⇧Tab outdents, as in any editor; with the [[ completion list open it accepts the highlighted note. To move focus out of the editor — to the buttons, the tabs, the REPL — use ⌥Tab (⇧⌥Tab goes backwards).

Preview

⌘E (or the preview button) swaps the editor for rendered Markdown. Wiki links become clickable there, images show, and diagrams are drawn. Press it again to go back.

Images

Paste an image (a screenshot, Copy Image in a browser), drop an image file on the editor, or use the image… button. The image is copied into an assets folder beside the note and a link to it goes in where the caret — or the drop — was:

![sales by region](assets/sales-by-region.png)

The editor shows the link; the preview and the PDF show the picture. The link is relative to the note, so it means the same thing to any other Markdown tool that opens the folder, and you can write one by hand for an image that is already in the workspace. An image that isn't where the link says is marked in place rather than silently left out. A name that is taken gets a number (chart-1.png); nothing is overwritten. Images are stored in the workspace, so a ↗ note opened in place has to be copied in before it can take one.

Diagrams

A fenced block marked mermaid is drawn as a diagram in the preview and in the PDF — sequence diagrams, flowcharts, class, state and Gantt charts, anything Mermaid knows:

```mermaid
sequenceDiagram
    Browser->>Server: GET /notes
    Server-->>Browser: 200 OK
```

The diagram follows the theme on screen and is always light on paper. If Mermaid can't read the block, the preview shows its source with the line it stumbled on just above.

Themes

The ☀/☾ button toggles between a light theme (Solarized) and a dark one (One Dark). Your choice is remembered.

PDF export

PDF renders the current note and opens the system print dialog, where Save as PDF produces a file. Headings, lists, tables, code blocks, images and diagrams all come across.

Running code from a note

Select any EELisp in a note — a line, a few forms, an inline `(+ 1 2)` — and press ⌘⇧↵. The result appears in the REPL, which opens if it was hidden. Surrounding backticks and fence lines in the selection are ignored.

For code you'll run again, a fenced block saves the selecting: with nothing selected, ⌘⇧↵ runs the whole eelisp block the caret is in — a live notebook without leaving the document.

```eelisp
(items :category "work" :when-before (today))
```

4. Tabs

Opening a second note puts it in a new tab rather than replacing what you had. Click a tab to switch, click its × to close. From the keyboard, ⌃Tab and ⌃⇧Tab go to the next and previous tab, ⌘1…⌘8 to the first eight and ⌘9 to the last (Ctrl+1… on Windows and Linux). These are built in; a (bind …) of your own on the same key replaces it — see keybindings for the next-tab, prev-tab, tab-1…tab-8, last-tab and close-tab commands. Inside a running form with windows of its own, ⌃Tab cycles those instead.

A tab marked ↗ is a file from outside the workspace; see §7.

5. Finding things

Quick open — ⌘P

Fuzzy matching over every file in the workspace. Type fragments in any order — agmt finds agenda-meeting.md. Arrow keys move, ↵ opens, Esc dismisses. Actions whose names match what you typed come under the files, marked ›.

Actions — ⌘⇧P, or > in ⌘P

Type > first in ⌘P — or press ⌘⇧P, which starts there — and it finds something to do instead of a file: >sweep, >new mind map, >clock. ↵ runs it in the note in front, as if you had pressed its key there, and each row shows that key on the right — the list teaches you the shortcuts. Backspace over the > and you're back among the files.

Nothing in the list is written by hand. It's gathered each time it opens, from:

WhereNamed by
every (bind …) in your keybindingsthe ; comment right above it — ;; ⌘⌥S — Sweep: scan every note… is Sweep, the rest its second line
your (action …) linestheir label — see actions of your own
the header's buttonstheir label, as another word that finds the key they press
the app's commandsNew mind map, Export as PDF, Theme: dark…
the folder's formsRun form: Agenda

What you type is matched against the name first, then the second line, the button's label and the function the key calls — so >scan tasks finds Sweep as well — and letters close together count too: >nmm is New mind map. The one you picked last comes first next time.

Full-text search — ⌘⇧F

Searches the contents of every note and lists each hit with its line. Choosing one opens the note at that line.

Tags

Any #hashtag in a note is indexed and appears in the tags panel in the sidebar, with a count. Clicking a tag opens full-text search filtered to it, which is the fastest way to see everything you marked #idea or #invoice.

With the PIM installed (see the agenda) the tags section is a form, pim/Tags.eeform: a box to narrow the list, every tag with how many notes have it — #work/l2l nested under work, whose count takes in its children's — and the notes with the tag you pick. Click one to open it. It refreshes when a note is saved.

Links to a tag's notes, in a note

⌘⌥B asks for a tag and writes a block at the caret with a [[link]] to every note that has it — the note itself left out. ⇩ links into the note in the tags panel does the same for the tag picked. ⌘⌥U brings it up to date after you tag another note.

<!-- eel: (pim-tagged "work") -->
- [[2026-10-28]]
- [[site]] · projects
<!-- /eel -->

7. Files from outside the workspace

There are three ways a foreign file arrives:

  • Drag it onto the EEditor window.
  • Open With from Finder or your file manager, or double-click if EEditor is the default for that type.
  • ⋯ → Open a file… above the file tree.

However it arrives, EEditor asks once what you want:

  • Copy into the workspace — it becomes an ordinary note, indexed for search, tags and links.
  • Open in place — the tab is marked ↗ and saves write back to the original location, wherever that is.

An in-place file is deliberately not part of the workspace: it won't appear in the tree, in search results, in the tag index or in backlinks. If you decide you want it after all, press copy in in the header and it becomes a real note — including whatever unsaved edits are on screen, which also makes this the escape hatch for a read-only original.

EEditor registers itself for .md, .markdown, .txt, .eelisp and .lisp, as an alternative handler rather than stealing those types from your existing editor. It can open far more than that — any text file — but only these types are offered by the system; for the rest, drag the file in, use ⋯ → Open a file…, or pick EEditor through Finder's Open With → Other….

8. The agenda

The agenda is a personal information manager in the Lotus Agenda tradition: you throw text at it, and structure emerges from rules rather than from forms you fill in.

Adding items

The box at the top of the agenda panel takes plain language and parses it — call Bob tomorrow !! becomes an item due tomorrow at high priority. Dates, priorities and people are recognised as you write them.

Tasks typed in notes

A - [ ] … line typed in a note is only text until it is linked to an item — it gets a {#12} at its end when it is. Under its items the agenda panel lists the lines that aren't yet, in a folding group, in notes, not in the agenda · N, by note. Click one to open its note at that line; + makes it an item and links the line. Lines inside ``` fences are examples and are left out.

Editing an item

Click one to expand its editor, which holds:

  • text — the item itself
  • when — a due date
  • priority
  • notes — free text underneath
  • recurrence — daily, weekly or monthly, with an interval
  • categories — one or more, from those you've defined

Categories

Categories are hierarchical: work/clients/acme is a child of work/clients. Assigning a child implies its parents, and an exclusive parent allows only one of its children at a time — so an item can't be both status/open and status/done.

Rules

Rules are the part that makes the agenda feel alive. A rule is a condition and an action; run them and every matching item is filed automatically. Open the setup panel to define categories and rules, then apply them.

(defrule invoices
  :when   (str-matches text "invoice|bill|payment")
  :assign "finance/invoices")

Inside a condition the item is in scope: text, notes, id, categories and props are bound, along with (get "field"), (has-category "work"), (overdue?) and (match n) for the capture groups of the last regular expression. Because rules are stored as EELisp and re-evaluated, anything the language can express, a rule can decide.

Views

A view is a saved filter with optional grouping — everything overdue, grouped by category. Define with defview, list with views, display with show.

The calendar

The calendar button opens a month grid of dated items. Drag an item to another day to reschedule it; the change is written straight back.

Recurrence

A recurring item regenerates when completed. every handles the date arithmetic — daily, weekly and monthly, with intervals like every 2 weeks — without depending on an external calendar.

The PIM: notes kept in step with the agenda

The PIM is a set of keys and functions, written in EELisp, that ties notes and the agenda together: a line like - [ ] pay the rent tomorrow !! {#12} is agenda item 12, ticked in the note when it's done in the panel and the other way round. With it come capture (⌘⌥N), refile (⌘⌥F), reminders, a time clock (⌘⌥I/⌘⌥O), a tracker (⌘⌥L), live blocks that refresh with ⌘⌥U, and a weekly review (⌘⌥V).

Its keys aren't part of the defaults. A folder gets them with the sample notes, or — for a folder you already use — right-click the workspace and choose Install the PIM keys…: they're added to your keybindings, your own keys stay as they are, and ⌘⌥K answering No clock running says they're live. The PIM guide has every key and function.

It puts two buttons in the header — ↻ update (⌘⌥U, recompute the note's lists) and ⇄ sweep (⌘⌥S, every note in step with the agenda) — and every key of it is in ⌘P's actions by name, with a few more that have no key: Insert the tasks in a category…, Insert a board of a category…, Insert the inbox, Open the Agenda window.

The sidebar's agenda, as a form

The PIM draws the agenda section as a form, pim/Side.eeform, in place of the built-in list: a quick-add box (the PIM's own reading of the line — #tags become categories, text after | its notes), N overdue · N today · N open, every category with how many open items it holds — children indented under their parent, the parent's count taking theirs in — and the items by date, overdue first. Pick a category to list only its items. ✓ done finishes the one picked; ⤢ agenda opens the big window. The in notes group stays under it.

It is an ordinary form, put there by one line in your keybindings — (panel "agenda" "pim/Side.eeform"), see panels. Open it in the designer to change what it shows, point the line at a form of your own, or take it out for the built-in list.

The agenda, big

⤢ above the sidebar's agenda opens pim/Agenda.eeform filling the window — the PIM as one interface, a tab each: Start here (is it all live, every key), Agenda (quick add, filters, an editor for one item), Board, In notes, Capture, Clock, Tracker and Review. ⤢ in its title bar, or a double-click on the title, gives the room back.

A board

The Board tab is a kanban of your categories. Pick one under Columns and its children are the columns — work/l2l, work/admin, then work itself for what is filed under it alone — or the top-level categories, with no category last. An item shows once, in the first column that holds it. Drag a card to another column, or press ⌥← / ⌥→ on the one picked, and the item is refiled: out of the categories that put it in the column it left, under the new one. Double-click a card to edit it.

Lists in a note

Any of the agenda's lists can live in a note, as a block that ⌘⌥U recomputes: the expression goes in the opening line, its result between the two. The preview draws it and the PDF prints it.

<!-- eel: (pim-category "work") -->
<!-- /eel -->
BlockShows
(pim-agenda 7)overdue, then the next seven days, as task lines — ⌘⌥W inserts it
(pim-category "work")the items in a category and its children
(pim-someday) · (pim-inbox)the undated items · what capture put in the inbox
(pim-board "work")the board as a table, a column per child category — ⇩ Into the note on the Board tab inserts it
(pim-tagged "work")a link to each note with the tag — ⌘⌥B inserts it

The task lines a block writes are the items themselves, {#12} and all: ⌘⌥T on one there finishes it in the agenda.

9. Sheets

A sheet is a grid of cells whose formulas are EELisp. Each one is a file in your workspace — Budget.eesheet — so it sits in the file tree, opens in a tab, and can be renamed, moved or revealed like any note. Right-click a folder and choose New sheet…, or type a name ending in .eesheet into +.

What a cell holds

What you type decides it:

  • 1200 — a number
  • rent — text, and '42 keeps something number-shaped as text
  • =(sum B1:B2) — a formula: one EELisp expression after =

A number written the way you'd write it is read as one, and takes the format it was wearing: 50% becomes 0.5 shown as a percentage, $1,200 and R$ 1.200,50 become money, 1 200,50 becomes twelve hundred and a half. Whichever of . or , comes last is the decimal point, and a lone comma before three digits is separating thousands — 1,200 is a thousand two hundred, 1,5 is one and a half. A format you set yourself is never overwritten.

2026-09-16 is a date — the spelling EELisp itself uses, so it sorts properly and date-add and date-diff work on it. Other spellings stay text: 16/09/2026 and 09/16/2026 are the same ten characters in two languages, and a sheet guessing which half is the month would be worse than leaving it alone.

A formula can call anything in scope — a builtin, something from the snippets, a defn you wrote in a note — and can read the database. Cells are named the way the next section describes.

=(sum B1:B9)
=(/ B10 12)
=(if (> B10 1500) "over" "ok")
=(count-records contacts)

Change a cell and everything that depends on it recalculates, in order, and is written at once — a sheet has nothing to save. Formulas that read each other in a circle show #CYCLE, a formula that failed shows #ERR, and one that reads a failed cell fails too, naming the cell where the trouble started. The line under the formula bar spells out the error of the selected cell.

Referring to cells

You writeIt means
B1what is in B1
B1:B9those nine cells as a list — what sum and its friends take
A1:C9a block, as a list of rows — (("apple" 3 "red") ("pear" 5 "green") …) — so a lookup knows where each row ends
E:E · B:Dwhole columns, read down to the last filled row: a new row at the bottom is counted too
$B$1B1, pinned: copy the formula somewhere else and it still reads B1
B$1 · $B1only the row pinned · only the column pinned
Rates!A1 · Rates!E:EA1, or column E, in the sheet called Rates, sitting beside this one

References are written in capitals: B1 is a cell, b1 is an ordinary name. An empty cell reads as nil, and sum, avg, min and max pass over blanks and text rather than complaining about them. A cell whose formula pointed at something you deleted shows #REF!.

Formulas worth knowing

Every one of these was run in a sheet before it was written down. The ? at the end of the toolbar keeps the same list inside the app, next to the formula bar where the question comes up: click a formula there and it is typed into the selected cell, so ↵ keeps it and Esc leaves the cell as it was.

FormulaGives
=(sum B1:B9)the total
=(avg B1:B9) · =(min B1:B9) · =(max B1:B9)the mean, the smallest, the largest
=(length B1:B9)how many cells the range covers
=(round (/ E1 3) 2)a third of E1, to two decimals
=(/ B1 $E$1)B1's share of the total in E1 — pinned, so it survives being filled down
=(if (> E1 1500) "over" "ok")one answer or the other
=(str A1 ": " B1)rent: 1200 — text stuck together
=(str-join ", " A1:A3)rent, food, travel
=(sum (map (fn (x) (* x 1.1)) B1:B3))ten per cent on each, then the total
=(sum (filter (fn (x) (> x 400)) B1:B3))only the ones over 400
=(date-add C1 1 :months)a month after the date in C1
=(date-diff C3 C1)the days between two dates
=(date-format C1 "EEEE")Monday — and "dd/MM/yyyy" for the date written that way
=(date-format (today) "yyyy-MM-dd")today. (today) on its own is an instant — a number — so it needs dressing
=(count-records contacts)rows in a table, and :where "city = ?" :params '("Lisbon") narrows it
=Rates!A1 · =(sheet-get "Rates" "A1")a cell of another sheet, named or worked out as it runs

A cell can call anything the language knows, including the functions you write in your own notes. In the REPL, (functions "date-") lists what is in scope and (source sum) shows how one was written — the EELisp reference has them all.

Spreadsheet functions

The functions you know from other spreadsheets are there in every sheet, with nothing to load. They are written in EELisp, so (source countif) in the REPL shows exactly what one does. Text compares the way a spreadsheet compares it, ignoring case: "Work" matches work.

FormulaGives
=(countif E:E "Work")how many cells in column E say Work
=(sumif E2:E99 "Work" F2:F99)the hours in F on the rows where E says Work
=(countifs E:E "Work" F:F ">2") · =(sumifs F:F E:E "<>Work")several conditions, all on the same row — sumifs takes the range to add first
=(averageif E2:E99 "Work" F2:F99)the mean of the matching rows
=(counta A:A) · =(countblank A1:A20)the filled cells · the empty ones
=(vlookup "pear" A1:C9 3)column 3 of the first row starting with pear
=(xlookup "pear" A1:A9 C1:C9 "none")the same, from two columns, with an answer for no match
=(match "pear" A1:A9) · =(index A1:C9 2 3)where it is, counting from 1 · what is at row 2, column 3
=(iferror (/ B1 C1) 0)the division, or 0 instead of an error when C1 is 0 or empty
=(ifs (> B1 90) "A" (> B1 80) "B" true "C")the answer after the first true test
=(switch B1 "eur" 1.1 "gbp" 1.3 1)the answer for B1's case, or the last one
=(median B:B) · (mode …) · (stdev …) · (var …)statistics — stdevp and varp for a whole population
=(large B:B 1) · =(small B:B 2) · =(rank B2 B:B)the biggest · the second smallest · B2's place, 1 for the biggest
=(roundup B1 2) · =(rounddown B1 2)rounded away from zero · toward it (round rounds to the nearest)
=(left A1 3) · (right …) · =(mid A1 2 3)pieces of text, counting from 1 as a spreadsheet does
=(trim A1) · =(proper A1)spaces tidied · Each Word Capitalised
=(textjoin ", " A1:A9) · =(concat A1 " " B1)joined, skipping blanks · end to end
=(unique A:A) · =(sort B:B)each value once · the numbers in order
=(sparkline B2:B30)a small line through the numbers, drawn in the cell — how a column is moving, at a glance; copied or exported it's bars, ▂▅▁█

A condition can be a value ("Work", 42), a comparison (">10", "<=5", "<>Done" for not), a wildcard ("W*" for anything starting with W, "?ork" for one character then ork), or a function of your own: (countif B:B (fn (x) (even? x))).

Your own functions

Put the functions you want in every sheet in .eeditor/functions.eelisp in the workspace — the open-functions command creates it. It is loaded at launch, right after EEditor's own, and again whenever you save it; reuse a name and yours wins.

(defn commission (amount) (* amount 0.05))

Then =(commission B2) works in any sheet. A sheet doesn't recalculate because a function changed — press ↻ to bring it up to date.

Reading another sheet

Rates!A1 reads a cell of a sheet sitting next to this one, and Rates!A1:A9 a range of it — money/Q3!A1 for one in a folder. While both sheets are open, changing the one being read updates the one reading it. A sheet that wasn't open at the time catches up when you press ↻.

Opening a sheet runs nothing

Every cell keeps its last result, so a sheet opens showing the values it was saved with — no code runs because you opened a file, even one someone sent you. Formulas run when you change what they read, or when you press ↻, which recalculates all of them. That is also how to refresh a formula that reads the database or the clock, or one reading a sheet that was closed when it changed: those aren't tracked.

Moving around and typing

KeyDoes
arrows · Tab · ⇧TabMove; ⇧+arrows or a drag selects a range
typingReplace the cell; arrows then write it and move on
↵ or F2Edit what's in the cell; ↵ again writes it and moves down
EscLeave the cell as it was
⌫Clear the selection
⌘Z · ⇧⌘ZUndo · redo typing and formatting
⌘ASelect everything
⌘C · ⌘X · ⌘VCopy, cut, paste

The box left of the formula bar names the selection; type C3 or A1:B9 there to jump.

Copying and filling

Copying puts the values on the clipboard, tab-separated, so they paste into Numbers, Excel or a note as a table. Pasting back into a sheet is different: it types the formulas, and moves their references by however far they travelled — copy =(sum B1:B2) one column right and it adds up column C. A $ pins a row or column so it stays where it is.

The small square at the bottom-right corner of the selection is the fill handle: drag it down or across to repeat the selection, each copy's references moving with it — how a column of totals gets written once. Fill down and Fill right are in the right-click menu when the selection covers more than one row or column.

Printing

PDF in the header prints the sheet as a table — the values as they are shown, each cell keeping its formatting — through the usual print dialog, where you choose Save as PDF.

CSV in and out

Right-click a sheet in the file tree for Export as CSV: it writes the values beside it — the numbers themselves, without the currency symbols or thousands separators the grid shows, and each formula as what it worked out to. Right-click a .csv for Import as sheet, which makes a sheet next to it. A field that reads as a number becomes one and everything else stays text, so a formula smuggled into a CSV arrives as text rather than as something your machine runs.

Formats, columns and rows

The toolbar sets bold, italic, alignment, a number or date format, decimal places, wrapped text, lines around the cells, and the text and fill colours — all for the selection, and undo takes any of it back. A cell you fill but don't give a colour of its own gets ink that can be read on that fill.

Drag the edge of a column header to resize the column, or a row header's edge to change the row's height; double-click an edge to put it back to the default. Right-click a row or column header, or long-press on a touch screen, to insert or delete rows and columns. Formulas follow their cells when rows and columns move, and a formula that read a deleted cell shows #REF!. Inserting and deleting can't be undone.

Sheets from EELisp

The REPL, an eelisp block in a note and your keybindings reach the same sheets by name, and an open sheet shows the change straight away:

(sheet-get "Budget" "B3")
(sheet-rows "Budget" "A1:B2")      ; a table in the REPL
(sheet-set "Budget" "B2" "500")
(sheet-put "People" "A1" (query contacts :order "name"))

Every sheet function is in the EELisp reference.

10. Forms

A form is a window you draw — buttons, boxes, a grid — with EELisp behind its events. It is the Visual Basic and FoxPro way of putting a screen over a table, and it lives in your workspace as a file: Contacts.eeform sits in the tree, opens in a tab, and can be renamed, moved and searched like a note. Right-click a folder and choose New form…, or type a name ending in .eeform into +.

A form tab has three modes, switched at the top of the pane: design, code and run.

The designer

Three columns, as VB had them.

  • The toolbox, on the left. Click a kind of control, then click on the form to place one there — or drag out the size you want.
  • The canvas, in the middle, the size of the form. Click a control to select it, drag to move it, pull a handle to resize it. ⇧-click adds to the selection, a drag from empty canvas draws a box around several, ⌘A takes them all. Arrows nudge by a grid step (⇧ by a pixel), Delete removes, ⌘D duplicates, ⌘C ⌘X ⌘V copy, cut and paste — into another form's tab too. ⌘Z undoes, because every change is one edit to the file. The small square at the bottom right resizes the form itself.
  • The properties, on the right: the selected control's name, position and size, what it holds, and its events. With several selected the panel becomes align, same size and spread evenly, all relative to the one you picked last — drawn with the heavier outline. With nothing selected it shows the form: title, size, menu, and what runs when it loads.

The file

Switch to code and there is no second language. The form is EELisp: one (form …) holding the layout, which the designer rewrites as you work, followed by your own code — a table, and the handlers the events name. Edit either half; the other follows.

(form "Contacts" :size (480 400) :on-load load-contacts
  (label   lblName :text "Name" :at (16 20) :size (72 24))
  (textbox txtName :required true :at (96 16) :size (240 28))
  (label   lblAge  :text "Age" :at (16 56) :size (72 24))
  (textbox txtAge  :number true :min 0 :max 150 :at (96 52) :size (80 28))
  (button  btnSave :text "Save" :submit true :default true :at (96 96) :size (88 30) :on-click save-contact)
  (grid    grdAll  :columns ("name" "age") :sortable true :filter true :page-size 5
           :at (16 144) :size (448 232) :on-change pick-contact))

(deftable contacts (name:string age:number))

(defn load-contacts (f)
  (ui-set "grdAll" :rows (query contacts :order "name")))

(defn save-contact (f)
  (insert contacts {:name (ui-get f "txtName") :age (ui-get f "txtAge")})
  (ui-set "txtName" :value "")
  (load-contacts f))

(defn pick-contact (f)
  (let (row (ui-get f "grdAll"))
    (ui-set "txtName" :value (dict-get row "name"))
    (ui-set "txtAge" :value (dict-get row "age"))))

Attaching code to an event

Double-click a button. That names its on-click handler after it, writes an empty (defn btnSave-click (f) …) at the end of the file, and opens the code with the caret on it. The … beside any event in the properties panel does the same, and you can type any function's name there instead.

A handler is a function of the form. Its one argument, f, is the whole form as a dict keyed by control name, so (ui-get f "txtName") is what the box holds — text, a number, true or false, the chosen item, the selected row. To change the form, a handler queues changes; EEditor applies them, in order, when the handler returns.

In a handlerDoes
(ui-get f "ctl")the control's value
(ui-set "ctl" :value v)change what it holds — also :text, :items, :rows, :columns, :filter, :src, :file, :pages, :interval, :enabled, :visible
(ui-message "…")a toast
(ui-focus "ctl")put the caret there
(ui-close)stop the form
(ui-open "Orders")open and run another form, in a window

Everything else is the language: insert, query, update, sheet-get, your own functions. A grid takes what query returns as its :rows, and the row a user picks comes back as a dict, so (dict-get row "id") is what update and delete want. Handlers are ordinary functions: try one from the REPL with a dict of your own.

The controls

ControlHoldsOf note
label:text
textbox:value, text:multiline; :number makes it worth a number, or nil while empty; :placeholder, :pattern, :min, :max; :readonly to show text that can be copied but not typed over, :mono for code
button:text:submit checks the form first; :default is pressed by ↵ in a box, :cancel by Esc
checkbox:value, true or false
radiothe chosen one of :items
dropdown · listboxthe chosen one of :itemsa listbox also fires on-dblclick (or ↵)
gridthe selected row, a dict:columns; :rows from a query; :sortable headers; a :filter box; :page-size pages; :editable cells, with on-edit handed the row as edited
boardthe card picked, a dicta kanban: :rows as cards in a column per value of their :lane-by field; :lanes orders the columns; :card and :sub name what a card shows. A drag to another column, or ⌥←/⌥→, fires on-move with $from and $to
date:value, yyyy-mm-dd:min, :max
image:srca path beside the form, like a note's assets/x.png
tabsthe open one of :pagessee pages below
sheet:filea live .eesheet beside the form; :toolbar false for cells only
timerinvisible; fires on-tick every :interval ms while enabled

Every control has a name, :at, :size, :enabled, :visible and :anchor (see Maximizing below). Events are properties naming a function: :on-click for a button, :on-change for anything with a value, :on-load for the form. The order of the controls in the file is the Tab order when it runs; the properties panel has earlier and later buttons to move one.

Checking what was typed

A :submit button does three things before its handler runs. First each control's own rules: :required, :min and :max on numbers and dates, :pattern — a regular expression the whole text must match. The first one broken is marked, focused and named: Age must be at most 150. Then the button's :on-validate handler, if it has one — a function of the form that returns a message to refuse, or nil to go ahead. Then :on-click.

(defn check-contact (f)
  (if (str-contains (ui-get f "txtName") " ")
    nil
    "A contact needs a first and a last name"))

Running it

▶ run evaluates the file — the table gets defined, the handlers too — builds the controls, and fires on-load. What the form writes goes into the workspace's database, the same one the REPL and your notes see, so (browse contacts) shows what it saved. ■ stop is the way back to the designer, and F5 runs the form in front again, as it did in VB6.

Opening a form asks: ▶ Run or Design. Running a form runs its code, so Design is what ↵ picks and what Esc leaves you with — the designer runs nothing, the rule sheets and eelisp blocks keep, and a form someone sent you can be looked at first. That is the line (forms-open "ask") in your keybindings file: "run" opens forms running without asking, "design" in the designer, "code" as their text. A new form, and an app imported from an exported page, always open in the designer.

To keep a form open while you write, run it in a window: type (ed-form "Contacts") in the REPL, bind it to a key, or press ⧉ on a running tab. The window floats over the app, drags by its title bar, and comes back where you left it. (ui-open …) from another form opens one the same way.

Keys in a form

In a listbox or a grid, ↑ ↓, Home End and PageUp PageDown move the pick, and :on-change hears it; ↵ is a double-click. A form can take keys of its own as well: :on-key names the handler, and :keys the keys it takes, written as a (bind …) writes them. The key arrives as $pressed and the control that had the focus as $control, both read with ui-get.

(form "Two folders" :size (520 320) :on-load fill :on-refresh fill
  :on-key on-key :keys ("Mod-Right" "Mod-Left" "Tab")
  (listbox lstLeft :at (10 10) :size (245 300))
  (listbox lstRight :at (265 10) :size (245 300)))

(defn fill (f)
  (ui-set "lstLeft" :items (map (fn (e) (dict-get e :name)) (dir-list "inbox")))
  (ui-set "lstRight" :items (map (fn (e) (dict-get e :name)) (dir-list "archive"))))

(defn on-key (f)
  (let (key (ui-get f "$pressed") left (ui-get f "lstLeft") right (ui-get f "lstRight"))
    (cond (and (= key "Mod-Right") left)
            (ui-editor (ed-move (str "inbox/" left) (str "archive/" left)))
          (and (= key "Mod-Left") right)
            (ui-editor (ed-move (str "archive/" right) (str "inbox/" right)))
          (= key "Tab")
            (ui-focus (cond (= (ui-get f "$control") "lstLeft") "lstRight" true "lstLeft")))))

The keys a form names are its own while it has the focus, even one the app uses — ⌘B, say — but never while the caret is in a text box, where typing comes first. With no :keys, the form takes every key with ⌘, ⌃ or ⌥, and F1–F12. The modifiers come in the order Mod, Ctrl, Alt, Shift: "Mod-Alt-Left". After file commands a form hands to ui-editor — ed-move, ed-copy, ed-delete, ed-mkdir, ed-write, ed-append — its :on-refresh runs once they are done, so a listing shows the folder as it is now.

Maximizing, and controls that stretch

⤢ in a window's title bar — or a double-click on the title — fills the app with it, and EEditor remembers that per form. The form is still drawn at its :size; :anchor says which sides of the form a control keeps its distance to when there is more room. Anchored left and right it grows wider, right only it moves with the right edge, and the same for top and bottom. Unset is "tl": it stays where it was drawn. So a grid is "tlbr", the buttons under it "lb", an editor down the right "tr".

(grid grdItems :columns ("text" "when") :at (20 136) :size (660 496) :anchor "tlbr")
(button btnSave :text "Save" :at (700 612) :size (72 30) :anchor "rb")

A form in the sidebar

A (panel …) line in your keybindings draws a form in the sidebar. "agenda" puts it in the agenda section, in place of the built-in list; "tags" in the tags section, in place of the cloud; any other name makes a section of its own.

(panel "agenda" "pim/Side.eeform")
(panel "tags" "pim/Tags.eeform")
(panel "Habits" "habits.eeform")   ; a section of its own

There it has no title bar and is as wide as the section — anchor its controls "tlr" to stretch with it. Its :on-refresh handler runs whenever the agenda may have changed (a key, the REPL, another form) and when a note is saved, which is how it stays current; it doesn't redraw while you are typing in it. Saving the form's file runs the new version there, and taking the line out brings back what the section had.

(form "Count" :size (224 48) :on-load fill :on-refresh fill
  (label lblCount :at (8 8) :size (208 24) :anchor "tlr"))
(defn fill (f) (ui-set "lblCount" :text (str (item-count) " items")))

Menus, pages, and a sheet in a form

A :menu on the form puts a menu bar under its title; each item names the handler it runs, "-" is a separator. The designer edits it as text — a title on its own line, items indented as label = handler.

(form "Contacts" :size (480 400)
  :menu (("Contacts" ("Reload" load-contacts) ("-") ("Close" close-form))
         ("Help" ("About" about)))
  …)

A tabs control lists its :pages, and any control with :page "Details" belongs to that page and shows only while it is open. Controls stay flat in the file with their own coordinates — draw them inside the tabs control's box — and the designer shows one page at a time; click a tab header on the canvas to open another.

A sheet control shows a .eesheet beside the form as the real grid, formula bar and all. Handlers read and write it with sheet-get and sheet-set, and the grid follows.

Example forms

Eleven forms to download, run and take apart. Save one into your workspace — any folder — and it appears in the tree; press ▶ run, then switch to code to see how it's done. Each file opens with a comment saying what it shows, and every handler has a line above it saying what it does. They write to your workspace's database, into tables of their own (books, customers, orders…), and the ones that start empty have a menu item that adds sample rows.

Try them first, with nothing installed: Office runs five of them as one app, and Functions browses the whole language — right here, in your browser. Each is a single HTML file exported from EEditor, the EELisp engine inside it; what you type into them stays in this browser.

Contacts — the first one to read

Contacts.eeform · A table, a grid, and boxes to fill it. Required and number boxes, a submit button with its own check, an editable grid with sorting, a filter and pages, and a menu — nearly everything in this chapter, in sixty lines.

Books — one record at a time

The Books form: title, author, year and notes boxes, record navigation buttons, and New, Edit, Save, Cancel, Delete

Books.eeform · The dBASE way of working through a table: |◀ ◀ ▶ ▶| move record by record, Record 2 of 3 says where you are, and a find box jumps to the first title or author that matches. New, Edit, Save, Cancel (or Esc) and Delete do what they say. While you browse, the boxes are locked; while you edit, moving is. That is one function setting :enabled on every control, and it is the part worth copying:

;; Browsing: the boxes locked, moving allowed. Editing: the other way round.
(defn bk-mode! (mode n)
  (set! bk-mode mode)
  (let (editing (not (= mode "browse")))
    (for-each c '("txtTitle" "txtAuthor" "txtYear" "txtNotes" "btnSave" "btnCancel")
      (ui-set c :enabled editing))
    (ui-set "btnNew" :enabled (not editing))
    (ui-set "btnPrev" :enabled (and (not editing) (> bk-pos 0)))
    …))

Where you are is a variable, bk-pos, defined in the file with def and moved with set!. The file is evaluated when the form runs, so it starts at the first record every time.

Functions — the language, browsed

The Functions form: a filtered list of functions, the source of take, arguments typed in and the result of running it

Functions.eeform · Every function in scope in a grid you can filter: the builtins, the special forms, the macros, the snippets you've loaded, the functions in your notes, and the form's own handlers. Pick one and its source appears — for anything written in EELisp, the definition as it was written, comments and all. Type arguments the way you would at the REPL and press Run. Double-click a row to start from its parameter names.

It is built on two functions that return what (functions) and (source …) print, as data a program can use:

(function-list "str-j")
; → ({:name "str-join" :kind "builtin" :sig "(str-join sep parts) → string" :summary "Joins a list…"})

(source-text "take")   ; → the text (source take) prints

;; Run: the call is written out as text and read back, so arguments are typed as at the REPL.
(eval (parse (str "(" name " " args ")")))

Files — two folders side by side

The Files form: two panels, a project folder on the left with roadmap.md picked and an archive folder on the right, a name box with Rename, New folder and Go to, and the keys listed underneath

Files.eeform · A file manager in the Norton Commander tradition, for your workspace. Each panel is a folder, folders first, with sizes and dates. Tab goes to the other panel, ↵ opens a folder or puts a file in a tab, ⌫ goes up. ⌘→ and ⌘← move what is picked into the other panel's folder, ⌥⌘→ and ⌥⌘← copy it, ⌘⌫ sends it to the Trash. ⇧⌘→ and ⇧⌘← show this panel's folder in the other one, and ⌘G goes to a folder you type — notes/2026, /projects, ~Downloads/invoices. The box underneath renames, makes a folder, or goes to one. Nothing is ever written over, and each panel remembers its folder for next time.

It is about 140 lines of EELisp, comments included. (dir-list …) reads a folder; the moves are ed-move, ed-copy and ed-delete handed to the app, which keeps any tab open from a moved file in step and then runs the form's :on-refresh; the keys are :on-key (see Keys in a form). The moving, in short:

;; From one panel's pick into the other panel's folder — never over a name that is taken.
(defn fm-across (f from how)
  (let (row (fm-picked f from) to (fm-other from))
    (if (not (fm-real? row))
      (fm-say "Pick something first")
      (let (target (fm-join (fm-dir to) (dict-get row :file)))
        (ui-editor (if (= how "copy")
                     (ed-copy (dict-get row :path) target)
                     (ed-move (dict-get row :path) target)))))))

It works on the workspace and the folders you add outside it (⋯ → Add a folder outside the workspace…), which head each panel as ~Downloads/ — not the whole disk — and only in the app: an exported page has no folder to read.

Tables — any table, dBASE-style

The Tables form: the books table filtered by year and ordered, with the query it ran shown above the grid

Tables.eeform · Pick any table in the workspace. Narrow it with a where — plain SQL, year < 1985 — choose an order and a limit, and press Query. The line above the grid is the query exactly as it ran; paste it into the REPL and you get the same rows. Double-click a cell to change it, add an empty row, delete one, and Pack to remove deleted rows for good.

The table is picked while the form runs, so its name is in a variable. Any database function takes the table as a bare name — (query books) — or as an expression that works one out:

(query (str tb-table) :where "year < ?" :params (list 1985))
(update (str tb-table) id {:title "…"})

Orders — master and detail

The Orders form: customers on the left, the picked customer's orders on the right with an amount column and a total

Orders.eeform · Two tables tied by a field — orders.customer holds a customer's id. Pick a customer and their orders appear, each with its amount worked out and a total underneath. Add an order, change one in place, delete one; the total follows.

(query orders :where "customer = ?" :params (list or-customer))

Agenda — the agenda, as a form

The Agenda form: tabs for items, quick add, categories and rules, and views; a grid of items and an editor for the picked one

Agenda.eeform · The agenda from chapter 8, and nearly every kind of control: a menu bar, tabs, a grid that sorts and pages, a date box, a radio group, dropdowns, a checkbox, and a timer that refreshes the list every minute.

  • Items — what's due today, in the next seven days, overdue, or all of it, or a search. Pick one to edit it — text, date, priority, category, notes — or double-click to mark it done.
  • Quick add — type it the way you'd say it; the box below shows what was understood before you add it.
  • Categories & rules — the category tree; define one; add a rule that files an item when its text contains a word; apply the rules now, or as items arrive.
  • Views — saved filters: overdue, text containing a word, or in a category.

An item read by id comes back whole with item->dict — notes included, which the grid doesn't carry:

(let (it (item->dict (item-get id)))
  (ui-set "txtNotes" :value (dict-get it "notes")))

defcategory, defrule and defview read their arguments as written — they are meant to be typed — so a form writes the call out as text and evaluates it, with json-stringify to put quotes around what was typed safely:

(eval (parse (str "(defrule " name
                  " :when (str-contains (str-lower text) " (json-stringify word) ")"
                  " :assign " (json-stringify category) ")")))

Office — the others as one app

The Office app: a list of screens on the left, counts of what is stored, and the Orders screen running inside it with its menu on Office's bar

Office.eeform · run it in your browser · Books, Orders, Tables, Agenda and Functions as the screens of one application, the dBASE way: pick one from the list and it fills the frame on the right; open another and the first stays open, with what you typed in it. The Window menu switches between them (⌃Tab too), closing one goes back to the one before, and the screen's own menus join Office's while it shows. Office remembers the last screen you used and opens it again next time. Download it with the five it opens, into one folder.

(form "Office" :main true :size (1040 640) …
  (listbox lstNav :items ("Books" "Orders" "Tables" "Agenda" "Functions") … :on-change office-go)
  (frame frmMain :at (192 8) :size (840 624) :on-change office-switched))

(public last-screen "" :persist true)    ; kept between runs

(defn office-go (f)
  (ui-open (ui-get f "lstNav") :in "frmMain"))   ; Books.eeform, beside Office

Three things make several forms one app. A frame is where the forms a form opens run — one at a time (:mode "screens"), as tabs, or as windows inside it. A variable declared (public …) is shared by a form and every form it opens, and a form with :on-public hears when another changes it; (local …) is one open form's own, so two copies of Books keep their own place. And a name in ui-open is found beside the form that asks, so the app's folder can go anywhere.

Display, Raycaster, MSX — screens: a picture, the keys, sound

A screen control is a picture the program draws. It evaluates its :frame expression :hz times a second and paints the frame that comes back — a dict saying what kind of picture it is and carrying its bytes. In the expression, ui-held is the keys held down ({"ArrowLeft" true}, by key code) and ui-ms the milliseconds since the screen started. Click a screen and it takes the keys; ⛶ fills the window with it.

(screen scrDemo :frame "(display-frame ui-held ui-ms)" :hz 35 :at (16 44) :size (480 324))

(defn display-frame (held ms)
  …                                    ; move the square, draw the picture with bfill
  {"kind" "indexed" "w" 160 "h" 100 "order" "columns"
   "pixels" (bytes->base64 display-fb) "palette" display-palette "border" 0 …})

An "indexed" frame is a byte a pixel into a palette of up to 256 colours; "rgba" is four bytes a pixel. A frame may also carry sound: "tones", notes for the browser to play ({"hz" 440 "ms" 120 "wave" "square"}), or "audio", 8-bit samples of its own. Sound starts at the first click on the screen, and 🔈 mutes it.

  • Display.eeform · run it in your browser · The one to read first: a sky and ground drawn a column at a time, a square the arrow keys move, a note while Space is held.
  • Raycaster.eeform · run it in your browser · A first-person maze: rays cast across a map, textured walls, sliding doors, lamps and barrels hidden behind the walls, footsteps — in about 10 ms a frame.
  • MSX.eeform · run it in your browser · A whole MSX1 in EELisp: the Z80, the TMS9918 video chip, the PSG sound chip, booting the free C-BIOS. Snake plays a game, Load cartridge… takes a .rom of your own — and Make an MSX game writes that snake from nothing, in Z80 assembly.

One file that runs anywhere

Right-click a form in the tree and choose Export as HTML…. EEditor writes one file beside it — Office.html — that runs in any browser, offline, opened straight from disk: the EELisp engine is inside it, compiled to WebAssembly, with the form and every form it opens. Nothing to install, nothing to host; e-mail it, put it on a USB stick or a web page. That is what the two links above are.

What the app stores is kept in the browser that runs it, between visits. Save data… under the app downloads it all as a SQLite file, and Open data… loads one back — how data moves to another computer or another person. Change the forms in EEditor and export again: the new file finds the data the old one kept, in the same browser.

11. The REPL

⌘J shows and hides the EELisp REPL. Type an expression, press ⌘↵, and the result appears above the prompt.

Results are not text dumps. A query renders as a real table; defform and edit render as a real form with working fields, including computed ones. This is dBASE III's interaction model, rebuilt.

λ (deftable notes (title:string body:string))
λ (insert notes {:title "Kickoff" :body "Ship by Friday"})
λ (browse notes)          ; → a table widget
λ (edit notes 1)          ; → a form widget

Asking the language what it knows

The ? in the panel's header is the short version: what a call looks like, how to name things, choosing, lists and dicts, your tables and agenda, and the handful of functions that show you the rest. Clicking a line puts it at the prompt rather than running it, to read and change before ⌘↵ sends it.

Two functions answer that from inside the REPL, so you never have to leave the app to look something up. (functions) lists everything in scope — every builtin, every macro, every special form, and anything you or the snippets bundle have defined — with its call shape. Give it a fragment of a name and it lists only those.

λ (functions "date-")
builtins
  date-add     (date-add date n unit) → string
  date-diff    (date-diff a b) → number
  date-format  (date-format d pattern) → string

— 3 of 186 functions matching "date-"

(source f) then shows one of them. For a builtin that's its manual entry — signature, what it does, a worked example. For anything written in EELisp it's the definition as it was written, comments and all:

λ (source zzalinhar)
zzalinhar — function

  ;; ─── zzalinhar — Text alignment ───
  ;; Aligns text to left, right, center, or justified within a given width.
  ;; (zzalinhar "hello" 20 :center)  → "       hello        "
  (defn zzalinhar (text width align)
    …

That applies to your own code too. A ;; comment written directly above a defn — in a snippet, in a note, in your keybindings file — becomes that function's documentation, and (source …) reads it back months later — as does a comment written after a one-line definition on the same line. The name is not evaluated, so (source map) works as well as (source 'map).

Snippets

The snippets button loads the standard EELisp bundle — ten modules and over a hundred and fifty functions covering text, maths, dates, conversion, crypto, reference tables, networking, world clocks and Bitcoin. Once loaded they're callable from the REPL and from your keybindings alike — and (functions "zz") is the fastest way to see what arrived.

The full language is documented at eelisp.app.

12. Keybindings

⌘⇧K opens .eeditor/keybindings.eelisp, writing the documented defaults into it the first time. That file is the shortcut table. Save it and it reloads at once; delete it and the built-in defaults apply again.

Writing a binding

(bind "Mod-i" body…)

Modifiers join with - or +. Mod is ⌘ on macOS and Ctrl elsewhere, and Cmd, Ctrl, Alt (Option) and Shift can be named directly: "Mod-Shift-f", "Ctrl-Alt-t". Every binding needs at least one modifier — function keys and Esc may go bare. Named keys are Left Right Up Down, Enter, Tab, Escape, Space, Backspace, Delete, Home End and PageUp PageDown. A later binding wins the same key, and unbinding one hands the key back to the editor's own behaviour.

What a binding returns

The body is EELisp, evaluated on the engine when the key fires. It returns editor commands as data, and a binding may list several — they run in order against the live document. The REPL carries out the same commands: type (ed-insert "hello") at the prompt and it lands in the note.

CommandEffect
(ed-cmd "save")run a built-in command (table below)
(ed-insert text)insert at the caret, caret after the text
(ed-insert-at pos text)insert at an absolute offset
(ed-goto pos)caret to an absolute offset
(ed-goto-line n)caret to the start of line n (1-based)
(ed-select from to)select a range
(ed-replace text)replace the selection
(ed-replace-range from to text)replace an explicit range
(ed-set-buffer text)replace the whole document
(ed-open "notes/x.md")open a file in a tab
(ed-new path)
(ed-new path text)
create the note if it doesn't exist, then open it — never overwrites
(ed-form "Contacts")open a form (chapter 10) and run it in a floating window
(ed-message text)show a toast
(ed-notify title body)a toast, and a system notification when EEditor isn't in front
(ed-prompt "When?" my-fn)ask, then run (my-fn answer) like a key
(ed-write path text)
(ed-write path text old)
replace a note's text, open or not — with old, only if it still says that
(ed-append path text)
(ed-append path text "## Log")
add to the end of a note, or of one of its sections
(ed-move from to)move or rename a file or folder; tabs open from it follow
(ed-copy from to)copy a file or a whole folder
(ed-delete path)into the system Trash; tabs open from it close
(ed-mkdir path)make a folder, and any above it that are missing
(ed-nothing)do nothing, useful as a cond branch

Files and folders

A key or a form can read the whole workspace and rearrange it. Reading happens in EELisp itself; changing goes through the app, so a tab open from a file that moves follows it.

CallGives
(notes) / (notes "reviews")every note (.md), or those under a folder
(read-note "inbox.md")a file's text as saved, or nil
(dir-list "notes")one folder's entries, folders first — each a dict with :name :path :dir :size :modified :ext; :hidden true adds the dot-names
(file-info "inbox.md")one entry, or nil
;; ⌘⌥J — file the note in front under journal/, by month: journal/2026-11/…
(bind "Mod-Alt-j"
  (ed-mkdir (str "journal/" (date-format *date* "yyyy-MM")))
  (ed-move *file* (str "journal/" (date-format *date* "yyyy-MM") "/" *file*)))

to is always the whole new path. Nothing is ever written over: a move or copy onto a name that is taken is refused, with a message, and so is any path that would leave the workspace.

Folders outside the workspace. ⋯ → Add a folder outside the workspace… in the desktop app grants one — your Downloads, say — and from then on a path names it as ~Downloads/…: (dir-list "~Downloads"), (read-note "~Downloads/notes.md"), (ed-move "~Downloads/invoice.pdf" "money/invoice.pdf"), and a note there opens in a tab like any other. (roots) lists what was granted. Only you can grant one, with the folder picker; a script can't, and the list is kept in EEditor's own settings, not in the workspace, so a folder someone sends you can't grant itself anything. Remove a folder outside the workspace… in ⌘⇧P takes one back. :modified counts seconds the way (now) does, so (date-format (dict-get e :modified)) reads it.

What your code can see

These are defined fresh before every run:

VariableIs
*file*path of the current note
*cursor*caret offset
*line* / *col*caret line and column, both 1-based
*line-text*text of the caret's line
*lines*number of lines in the document
*sel-from* / *sel-to*selection bounds
*selection*the selected text
*buffer*the whole document
*date*"YYYY-MM-DD", local time
*time*"HH:MM"
*now*"YYYY-MM-DD HH:MM:SS"

*selection* and *buffer* are only computed when your binding mentions them, so a shortcut stays fast in a large document.

The header's buttons

The buttons above the editor are the file's (button …) lines, left to right in the order they are written. A button can press a key, run a body as a binding does, or run one of the app's commands — and one that runs a command may leave its label out: EEditor labels it, hides it where it can't work, and shows it pressed while it is on. Delete a line and the button goes; move it and it moves.

(button "↻ update" "Mod-Alt-u")        ; does what ⌘⌥U does
(button "today" (ed-cmd "daily-note"))  ; a body, as a bind takes
(button (ed-cmd "toggle-theme"))         ; labelled by the app
(button (ed-cmd "insert-image"))
(button (ed-cmd "toggle-preview"))
(button (ed-cmd "export-pdf"))
(button (ed-cmd "open-keys"))
(button (ed-cmd "toggle-repl"))

A file with none of those six command buttons — one written before they were buttons — gets them anyway, after its own. Write one of them and the header is yours. (panel …) lines draw forms in the sidebar: see a form in the sidebar.

Actions of your own

An (action "label" …) line is written like a button, but it's listed only in ⌘P's actions — for the things worth finding by name that don't deserve a key or a place in the header. Its body is a binding's, or a key to press:

(action "Work tasks here"   (pim-insert-block "(pim-category \"work\")"))
(action "Open the roadmap"  (ed-open "projects/roadmap.md"))
(action "Sweep" "Mod-Alt-s")

A key needs no action line to be listed: the comment right above its (bind …) names it. A capitalised name before a colon is the whole name — ;; ⌘⌥G — Set the category: file the caret's task under one — and the keys written in front of the dash are left out.

Choosing what opens at launch

(on-start …) runs the same kind of commands once, after the workspace loads. With no on-start form at all, EEditor opens welcome.md while that is still around, and today's note — created if it doesn't exist yet — once it isn't.

(on-start (ed-open "todo.md"))         ; always this note
(on-start (ed-cmd "daily-note"))       ; today's note, created if needed
(on-start (ed-new (str *date* ".md")))  ; the same, spelled out
(on-start)                              ; nothing — an empty editor

An empty (on-start) is deliberately different from leaving the form out: one says "open nothing", the other says "decide for me".

Built-in commands

Anything here can be named by (ed-cmd "…"):

CommandDoes
savesave the current note
quick-openfuzzy file finder — and, after a >, the actions
command-palettethe same, opened on the actions
searchfull-text search
daily-noteopen (or create) today's note
new-file / new-folder / new-sheet / new-form / new-mindmapcreate at the workspace root, asking for a name
form-design / form-code / form-runswitch the active form tab's mode
open-filepick any file; asks whether to copy it in or edit in place
copy-into-workspaceturn the current ↗ note into a copy in the workspace
open-keys / reload-keysedit or re-read this config
open-functions / reload-functionsedit or re-read your own spreadsheet functions (.eeditor/functions.eelisp)
add-samplesadd the sample notes and the PIM tour to this folder, keeping what is there
install-pimadd the PIM's keys and library to this folder's keybindings — again later to update them; your own keys stay
aboutwho makes EEditor, and which version is running — also in the workspace's right-click menu
toggle-repl / focus-replshow/hide the REPL, or jump into it
focus-editorput the caret back in the document
toggle-previeweditor ⇄ rendered Markdown
toggle-themedark ⇄ light
export-pdfprint the current note to PDF
insert-imagepick images; each is copied into assets/ beside the note and linked
snippetsload the standard EELisp bundle
calendaropen the month grid
agenda-setupcategories and rules panel
agenda-refreshre-read the agenda panel — after a script changed items behind its back
close-tab / next-tab / prev-tabtab management
toggle-sidebarshow or hide the sidebar — ⌘B
tab-1 … tab-8 / last-tabgo to that tab — ⌘1…⌘8, ⌘9 the last
new-windowopen another folder in a window of its own

Worked examples

;; A timestamped heading at the caret, on its own line
(bind "Mod-Alt-h"
  (ed-insert (str (cond (= *col* 1) "" true "\n") "## " *time* "\n\n")))

;; Wrap the selection in bold
(bind "Mod-b" (ed-replace (str "**" *selection* "**")))

;; A task line
(bind "Mod-Shift-t" (ed-insert "- [ ] "))

;; Link back to today's note
(bind "Mod-Shift-b" (ed-insert (str "[[" *date* "]]")))

;; File this note under a dated folder and tell me about it
(bind "Mod-Alt-n"
  (ed-new (str "journal/" *date* ".md") (str "# " *date* "\n\n"))
  (ed-message "journal entry ready"))

A daily note that starts with your lists

daily-template is what a new daily note starts with — ⌘D, the daily-note command, Today's daily note in ⌘P's actions:

(on-load
  (defn daily-template (day)
    (str "# " day " · " (date-format day "EEEE") "\n\n## Today\n\n- [ ] \n\n## Notes\n\n")))

With the PIM, ⌘D makes the note from pim-daily-template; define that one instead and the note can open with live lists — each a block that's filled when the note is made and that ⌘⌥U refreshes through the day. A list of headings and blocks keeps it easy to change:

(on-load
  (def my-daily-blocks
    (list (list "Agenda" "(pim-agenda 1)")
          (list "Work"   "(pim-category \"work\")")
          (list "Inbox"  "(pim-inbox)")))
  (defn pim-daily-template (day)
    (str "# " day " · " (date-format day "EEEE") "\n\n"
         (str-join "" (map (fn (b) (str "## " (first b) "\n\n" (pim-block (second b)) "\n\n"))
                          my-daily-blocks))
         "## Log\n\n\n## Notes\n\n")))

Mistakes are reported, not swallowed: a binding that fails prints its error in the REPL and shows a toast naming the key, and problems in the config itself are listed when it reloads.

13. iPhone and iPad

Below 900 pixels the layout becomes a single pane with a tab bar — Files, Editor, REPL — so the same app works on a phone without pretending to be a desktop.

The workspace is the app's Documents folder, exposed in Files → On My iPhone → EEditor. Anything you put there appears in EEditor, and anything EEditor writes is visible to other apps and to whatever backup or sync you already use.

Above the file tree, ⋯ → Open a file… imports a single document through the system picker, and ⋯ → Open another folder… points EEditor at an external folder — an iCloud Drive directory, for instance — and remembers it across launches.

14. Shortcut reference

The defaults. ⌘ is Ctrl on Windows and Linux.

KeyDoes
⌘SSave
⌘PQuick open — type > for actions
⌘⇧PActions: everything EEditor can do, by name
⌘⇧FFull-text search
⌘DToday's note
⌘IThe date and time at the caret
⌘⇧INew note stamped with the minute
⌘⇧NNew file
⌘EPreview ⇄ edit
⌘JShow/hide the REPL
⌘⇧KEdit these keybindings
⌘⇧↵Run the selection as EELisp, or the eelisp block at the caret
⌘↵Evaluate in the REPL
⌘-clickFollow a wiki link from the editor
⌃Tab / ⌃⇧TabNext / previous tab; ⌘1…⌘8 pick one, ⌘9 the last
⌘WClose the tab — the window, once there is none
⌘BShow/hide the sidebar; drag its edge to resize it
F5Run the form in front
⇧⌘WClose the window (the File menu's)
⌃⌘NNew window, on another folder (the File menu's)

Most of these are a line in your keybindings file. The tab keys, ⌘W, ⌘B and F5 are built in, and a (bind …) of your own on one of them replaces it; ⇧⌘W and ⌃⌘N belong to the File menu. The PIM adds its own ⌘⌥ keys — ⌘⌥T a task, ⌘⌥U update the blocks, ⌘⌥B links to a tag's notes and the rest; the PIM guide lists them all.