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, press open… at the top of the file tree and choose any directory — a git repository, a synced folder, an external disk. The choice survives restarts.

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 outside your notes: the agenda database (items, categories, rules) and any tables you create in EELisp live in an SQLite file in the application's own data directory — not in your workspace. Your notes are never inside it.

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.

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. ⌘⌥→ and ⌘⌥← move between tabs if you bind them — see keybindings for the next-tab, prev-tab and close-tab commands.

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.

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.

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.
  • The file… button in the sidebar.

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 the file… button, 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.

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.

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
$B$1B1, pinned: copy the formula somewhere else and it still reads B1
B$1 · $B1only the row pinned · only the column pinned
Rates!A1A1 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.

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

There is no separate list of "spreadsheet functions": 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.

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
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
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 and :visible. 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. Opening a form runs nothing: only run does, the same rule as sheets and eelisp blocks.

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.

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

Seven 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 ")")))

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.

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

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. 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-nothing)do nothing, useful as a cond branch

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.

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
searchfull-text search
daily-noteopen (or create) today's note
new-file / new-folder / new-sheet / new-formcreate 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
add-samplesadd the sample notes and the PIM tour to this folder, keeping what is there
toggle-repl / focus-replshow/hide the REPL, or jump into it
focus-editorput the caret back in the document
toggle-previeweditor ⇄ rendered Markdown
toggle-themelight ⇄ dark
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
close-tab / next-tab / prev-tabtab management

Worked examples

;; A timestamped heading at the caret, on its own line
(bind "Mod-Shift-i"
  (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"))

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.

file… imports a single document through the system picker. 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
⌘⇧FFull-text search
⌘DToday's note
⌘INew note stamped with the minute
⌘⇧ITimestamped heading at the caret
⌘⇧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

Every one of these is a line in your keybindings file, and every one can be changed.