Offline-first notes where every line is also a live calculator. Write freely; any line that looks like arithmetic is worked out as you type and its result appears in a gutter beside it.
Lisbon trip budget
Flights for two
flights = 412 eur 412.00 EUR
flights to usd 478.29 USD
Food and getting around // rough guess
daily = 55 eur 55.00 EUR
daily * 7 385.00 EUR
total 2,288.00 EUR
One Flutter codebase, four platforms: macOS, Windows, iOS, Android.
flutter pub get
flutter run -d macos # or: windows, <ios device>, <android device>
flutter testBuilt on Flutter 3.47.2 against the standalone material_ui package
rather than package:flutter/material.dart. Material and Cupertino moved out
of the SDK in 3.44 and the in-SDK libraries are formally deprecated in the
November 2026 release, so this is where new code belongs; it also means design
fixes arrive on the package's own schedule instead of the quarterly SDK train.
cupertino_ui comes in transitively and backs the .adaptive widgets — there
is no direct import, so it is not listed as a dependency.
Verified on Flutter 3.47.2. Android needs JDK 17+ (flutter config --jdk-dir)
and SDK platform 36; iOS needs Xcode's iOS platform installed
(xcodebuild -downloadPlatform iOS). Goldens under test/goldens/ render
with macOS system fonts — regenerate them on macOS with
flutter test --update-goldens.
Desktop releases are cut by tagging. The tag must match version: in
pubspec.yaml, and a job checks that before anything expensive runs:
git tag v1.0.1 && git push origin v1.0.1.github/workflows/desktop-release.yml then builds a notarised DMG on a macOS
runner and the Inno Setup installer on a Windows one, uploads both to the
kapynotes R2 bucket under downloads/, and cuts a GitHub Release. The
landing page pins both filenames, so bump them in
website/src/pages/index.astro and redeploy — the release job prints this
reminder in its summary.
Tags rather than pushes because the artifacts are named after the version and
served immutable for a year: republishing one filename would leave Cloudflare
edges serving the old bytes indefinitely.
.github/workflows/desktop-ci.yml runs on every push instead, building both
desktop platforms unsigned and keeping them as artifacts. Windows has to be
built there — flutter build windows needs a Windows host with Visual Studio's
C++ workload, and macOS does not offer the subcommand at all. Note the billing
multipliers on a private repo: Windows 2x, macOS 10x.
Local builds still work exactly as before:
packaging/release.sh mac-direct # notarised DMG, using the keychain profile
packaging/release.sh mac-unsigned # same image, no certificate; what CI builds
packaging/release.sh ios # .ipa for App Store Connect
packaging/preflight_ios.sh --submission # complete iOS readiness checkThe app ships as Kapy Notes under bundle ID com.kapybara.kapynotes, signed
by Kapybara LLC (team 96V66447C6). SKIP_NOTARIZE=1 builds a DMG without
contacting Apple, for local testing. packaging/SUBMISSION.md covers the
certificates, the notarisation credential and the App Store metadata.
Finder writes the installer window's background and icon positions into a
.DS_Store, and scripting Finder needs a desktop session no CI runner has. So
the layout is captured once and committed as packaging/dmg/DS_Store — without
the leading dot, which .gitignore would swallow. build_disk_image copies it
in and refuses to build if it is missing, rather than quietly shipping Finder's
default window.
Regenerate it after changing the window geometry constants in release.sh or
the artwork, then commit the result:
packaging/release.sh dmg-templateThe Windows wizard would otherwise wear Inno Setup's own pictures. Two replace
them, both committed under packaging/windows/: wizard-small.png is the mark
in the top right of every page, and wizard-image.png the tall panel down the
left of the Setup Completed page. The .exe's icon is separate again, and
already the app's — that is SetupIconFile.
They are drawn by AppKit, from the same palette and the same waving Kapy as the
disk image, so the two downloads read as one product. Which also means they
cannot be drawn on the Windows runner that builds the installer, so the output
is committed and iscc only ever reads it:
swift tool/generate_windows_installer_art.swiftKapy himself comes from design/mascot/ in the kapynotes repository beside
this one; the script fails rather than draw the panel without him. Both images
are written at Inno's 250% DPI sizes — 534x1022 and 159x159 — and Inno scales
them down, as far as 202x386 on a 100% DPI screen. The small image keeps its
own transparent inset because Inno anchors the image control to the top-right
edge of the window. The panel carries no body copy: a tagline survives the
scaling at about eight pixels tall.
The Welcome page, which would show the panel at the start rather than the end, is off by default in Inno 6 and left that way. It is a page whose only job is to be looked at, and skipping it is one less click.
Desktop builds schedule a quiet check of dl.kapynotes.com/latest.json five
seconds after launch and resume. The checker makes a network request at most
once every 24 hours after a successful response; failures retry after two
hours. The check is only a few hundred bytes of JSON.
When it finds a newer release, the app downloads it in the background — the Download updates automatically switch in Settings → Updates, on unless turned off — and checks its signature. Nothing is asked of the user until then. Once it is ready, an Update and restart row appears in the notes list above Settings, and that one click installs the release and relaunches into it. With the switch off, Settings offers Download instead, and the same row appears when that finishes. A failed download says why in Settings and tries again two hours later.
UpdateChecker in lib/data/update_checker.dart owns all of that; the
platform half is an UpdateInstaller:
- macOS: Sparkle, driven directly by
macos/Runner/AppUpdater.swiftover thekapynotes/updaterchannel. Sparkle's own scheduler stays off, because it puts its update panel on screen the moment it finds something. Instead the app callscheckForUpdatesInBackgroundwith automatic downloads allowed, which runs Sparkle's automatic driver: it downloads, verifies and prepares the release with no window, then hands the app animmediateInstallationBlockthroughwillInstallUpdateOnQuit. Update and restart invokes that block, and Sparkle installs and relaunches without showing anything. A prepared release also installs whenever the app quits, clicked or not. Automatic downloads needSUAllowsAutomaticUpdatesinInfo.plist: with automatic checks off, Sparkle refuses them otherwise and falls back to its panel. - Windows: the app itself. WinSparkle cannot download without its own
window on screen, so
WindowsUpdateInstallerdoes what it did, quietly: it downloads the installer named inlatest.json'swindowsblock into%LOCALAPPDATA%\com.kapybara\Kapy Notes\Updates(resuming a download that was cut off), checks the same DSA signature WinSparkle checked against the key compiled into the app (lib/data/update_signature.dart), and on the click runs it with WinSparkle's arguments and quits.
There are two appcasts because the two frameworks disagree about what
sparkle:version means — Sparkle compares it against CFBundleVersion (the
+N half of pubspec's version), WinSparkle against the ProductVersion string
in windows/runner/Runner.rc. The Windows one is only read by builds from
before the app downloaded its own updates, which still update through
WinSparkle; keep publishing it until nobody runs those. The release job writes
both, plus latest.json, with a five-minute cache header; they are the only
mutable objects in the bucket.
Each appcast and the in-app update notice point a release at its own
kapynotes.com/changelog/<version> page. WinSparkle, in those older builds,
embeds that page in its update dialog, so it shows only what changes in the
version being offered. That focused view uses the release's short highlights; Settings
keeps the complete changes for the browsable history. The release job
refuses to publish a feed until that page is live.
Because macOS compares build numbers, a release that forgets to bump +N would
tell every Mac it is already current. The verify job fails the release rather
than let that ship.
Signing keys — already set up. Both releases are signed and the public
halves are compiled into the app, so a hijacked feed cannot ship a payload. The
EdDSA public key is SUPublicEDKey in macos/Runner/Info.plist; the DSA one
is windows/runner/resources/dsa_pub.pem, copied into
lib/data/update_signature.dart — a test fails if the two ever differ, and
another checks the verifier against the signature 1.28.0 actually shipped
with. Their private halves are the
SPARKLE_ED_PRIVATE_KEY and WINSPARKLE_DSA_PRIVATE_KEY repository secrets,
and the release job fails loudly if either is missing.
The Sparkle private key also lives in the login keychain of the Mac it was generated on, which is the only copy that can be re-exported. Print the public key any time to check the plist still matches:
macos/Pods/Sparkle/bin/generate_keys -p # needs `flutter build macos` first
macos/Pods/Sparkle/bin/generate_keys -x key.txt # re-export for a new CI secretNote sign_update's -s flag is deprecated and now fails; the release job
uses --ed-key-file. The Windows key is plain OpenSSL DSA and can be
regenerated anywhere (then copy the new public key into
lib/data/update_signature.dart too):
openssl dsaparam -out dsaparam.pem 2048
openssl gendsa -out dsa_priv.pem dsaparam.pem
openssl dsa -in dsa_priv.pem -pubout -out windows/runner/resources/dsa_pub.pemRotating either key means the release after it cannot be installed by anyone still running an older build — their copy only trusts the key it shipped with.
The macOS app is sandboxed, which forbids it from replacing its own bundle, so
installation goes through Sparkle's Installer.xpc. That needs
SUEnableInstallerLauncherService in Info.plist and the two
mach-lookup.global-name temporary exceptions in Runner/*.entitlements —
remove either and updates fail at install time, after the download.
On Windows, the app runs the Inno installer with /VERYSILENT, as WinSparkle
did, which skips the [Run] entry the Setup Completed checkbox lives on; a second [Run] entry
guarded by Check: WizardSilent brings the app back instead. It cannot be left
to Restart Manager, whose restart only reaches applications that called
RegisterApplicationRestart. The install is per-user, so it raises no UAC
prompt — but see below for what SmartScreen still does.
Closing the running copy is the app's job, not just the installer's. Setup
uses Restart Manager, which asks a window to end with WM_QUERYENDSESSION,
then WM_ENDSESSION, and only falls back to WM_CLOSE for an app still
running after both. Flutter's runner answers none of the first two, and with
"keep running in the background" on — which is the default on desktop —
window_manager answers the third with -1 and the app hides to the tray. So
the window vanished, the process survived, its DLLs stayed locked and the
install failed on them.
windows/runner/flutter_window.cpp now answers all three. WM_QUERYENDSESSION
returns TRUE and nothing else, because Windows asks every application before
it tells any of them to go and one of the others can still call it off.
WM_ENDSESSION is the one that means it: it sends kapynotes/system_shutdown
to Dart, where DesktopIntegration.quit() does the same orderly exit the tray's
Quit does. The WM_CLOSE that follows is swallowed rather than acted on — that
quit is already writing, and it ends by destroying the window itself. If it has
not in ten seconds the runner leaves anyway, and CloseApplications=force lets
Setup terminate a copy that still has not, which is what every build older than
this one is.
An update replaces the app, not the account. The session token and the master
key live in the platform keystore — Keychain on macOS, and on Windows a
DPAPI-sealed file under %APPDATA%\com.kapybara\Kapy Notes, a path derived
from the CompanyName and ProductName in windows/runner/Runner.rc and so
stable across versions. The Inno installer only ever writes to
%LOCALAPPDATA%\Programs\Kapy Notes, and Sparkle only swaps the bundle, so
neither goes near either store.
What did sign people out was the app itself. Account.restore asked the server
who the stored token belonged to and read every failure as a revocation —
offline, DNS, a timeout, a 502 — and deleted the master key on the way out. So
a launch with no network came back signed out and asking for a passphrase, and
an update is exactly when that happens: the installer relaunches the app the
instant it lets go, and the app opens at login by default, often before the
network is up.
AuthApi.checkSession now answers with three states rather than two, and only
SessionRejected — the server actually saying no — clears anything.
SessionUnreachable keeps the session and carries on. That needs the account
to be known offline, so the signed-in user is cached beside the token in the
keystore and cleared with it; without that, a session restored offline would be
valid and belong to nobody.
The installer is not Authenticode-signed — there is no certificate yet — so SmartScreen shows "unknown publisher", and because reputation attaches to the certificate rather than the file, that will not improve across releases. Buying one needs a cloud HSM (e.g. Azure Trusted Signing) to sign from CI, since code-signing keys must now live on certified hardware.
Builds that still update through WinSparkle run that unsigned installer, and SmartScreen can warn each time. The app's own updater writes the installer itself, so it carries no Mark of the Web, and starts it directly rather than through the shell, so SmartScreen should have nothing to say about it — but that has not yet been tried on a real Windows machine. macOS has no equivalent problem — the DMG is Developer ID signed and notarised.
A purpose-built expression engine rather than a general maths library, because the interesting behaviour is in the natural phrasing, the unit algebra and the refusal to guess.
| File | Role |
|---|---|
lexer.dart |
Text → tokens. Absorbs grouped and radix numbers, currency symbols, quoted annotations and line comments. |
parser.dart |
Tokens → AST. Recursive descent; of/off/on/to/in/per are operators. |
evaluator.dart |
AST → value, against a scope that carries down the note. |
unit.dart, unit_registry.dart |
Dimensional algebra over ~110 units plus live currencies. |
temporal.dart |
Strict wall-clock and named-time-zone expressions that stay out of ordinary prose. |
notation.dart |
Binary, octal, hexadecimal and scientific result formats. |
engine.dart |
Runs a whole note top to bottom. |
format.dart |
Compact display text and full-precision copy text. |
highlight.dart |
Re-runs the lexer to colour the note. |
Percentages are a value type, not a text rewrite. 20% evaluates to a
PercentValue, so 1250 + 8% means "add 8 percent of 1250" while
0.08 + 1250 still means what it says. The same rule gives 20% of 80,
20% off 50 and 25 as a % of 200 without any of them being special-cased in
a regular expression.
Units carry dimensions. 100 km / 2 h produces 50 km/h because the unit
is a product of exponents, not a label. Conversions are offset-aware, so
100 degC to degF is 212 °F, while 20 degC + 5 degC treats the right side
as a difference and gives 25 °C.
A line that does not parse produces nothing. Someone mid-sentence is not someone with a syntax error, and "I have 3 apples" must not render a result. Lines are only attempted when they carry an arithmetic signal, and anything that fails to parse is left as plain text.
A colon is an explicit label boundary. The description on its left is
never evaluated, even when it contains model numbers or other amounts. Thus
7KvA Solar System : 12000rs is exactly 12,000.00 INR, and nested labels use
the rightmost colon. Compact clock times and ratios such as 12:30 and 1:2
remain punctuation rather than labels. Highlighting follows the same rule, so
numbers inside the description stay visually quiet.
A label followed by a marked amount is a value, even without a colon. Coffee $4.50,
Lunch 12 usd and Run 5 km each read as the amount and join the running
total, so a budget needs no = on every line. The marker carries the whole
rule: a bare trailing number is refused, because Lunch 12 and Room 12 are
the same shape and nothing in the text separates them. Writing a currency or a
unit is the user saying which one they meant. This is tried only after the
whole line has failed to parse, so 100 km / 2 h is still a division.
A number may say what it counts, and still be a number. 20 domains,
20 domains * 2, 3 users * $10 and $2/mailbox all give the amount, because
a word the calculator has no meaning for is a label rather than an error. The
labels are taken out and what is left is evaluated as ordinary arithmetic.
Two rules keep that from swallowing prose, and both are about position. The
line has to open with something the calculator understands, which is what
separates 20 domains from I have 3 apples and Room 12. And a word only
counts as a label directly after an amount, or after another label — which is
what separates 12 mangoes, where the word is what the number counts, from
10 min break, where the amount has already said what it is and everything
after it is prose. A word after an operator is an operand, so 12 + mangoes
stays a line still being typed. $2/mailbox is the one shape that needs more:
the label is what the rate is per, so the divide goes with it — while
$120 / 3 months, whose denominator means something, stays a rate.
x between two amounts multiplies. 3 x 4, 1920 x 1080, 2 x 3 widgets.
Only between two amounts, and never in a note that has assigned x itself,
because x is also the first name anyone gives a variable.
Running scope. A variable assigned on one line is available below it, and
prev, sum, total and avg accumulate as the note is read downward. A
line that is only total reports the total without adding itself to it.
The compact language also includes natural operators (plus, without,
times, divided by), implicit parenthesis multiplication, bitwise
operations, base literals, written number scales, bracketless functions,
factorials, mixed measurements, square and cubic units, case-sensitive SI and
data prefixes, CSS px/pt/em with configurable ppi, reverse percentage
questions such as 5% on what is 105, calendar arithmetic, Unix timestamps,
and DST-aware time-zone conversion. Quoted text is an inline annotation, so
$275 "Model 227" calculates the price without treating 227 as another value.
Flutter can style a text field's content directly, so this is one real,
editable, syntax-coloured TextField — not the transparent-textarea-over-a-
mirrored-div stack a browser forces on you.
Results are aligned by laying the note out a second time with the same width,
style and strut the field uses, and reading each line's offset from it. The
gutter and the field share one ScrollController. That combination is what
keeps a result pinned to its line through wrapping, scrolling and text scaling
— and it is asserted directly in test/note_editor_test.dart, which compares
chip positions against the field's own RenderEditable geometry.
Clicking a result copies it at full precision.
Typed and pasted web addresses stay ordinary note text, but appear as links. Tap one on touch devices, or Command-click on Apple platforms and Control-click elsewhere, to open it. Selecting or long-pressing a link exposes a dedicated Copy Link action while leaving the platform's normal text copy and paste intact.
Currency codes can sit directly beside an amount, such as 10usd or 10eur;
rs is accepted as an INR shorthand, so 10rs and 10inr are equivalent.
Lines that start with // are treated and styled as quiet comments.
Markdown in notes (Settings → General, off by default) reads a note as
CommonMark with GitHub's tables, strikethrough and task lists. The parser is
dart_markdown, the one Dart parser
that reports where every node and marker sits in the source, which is what
styling an editable field needs. A note is drawn the way GitHub renders one,
and edited in place like a word processor's page: the markers stay in the text
but are hidden, so a heading is large and bold with no # in sight, a list
has bullets and checkboxes that tick with a tap, code sits on a tinted panel,
a table is a grid, and a link is just its words. Inline markers show, quietly,
only while the caret has been moved against them (** either side of a bold
word, a link's address), and a code block's fences or a table's pipes only
while the caret is in it. The structure at the start of a line (## , > ,
- [ ] ) is never shown: the caret steps over it, Backspace at the start of
the words takes the formatting off, and # , - , 1. , > or [] typed
at the start of a line make one. Bold switched on with nothing selected
applies to the next word typed, Enter continues lists and quotes, and the
formatting buttons and shortcuts write markdown rather than styles kept beside
the text. Nothing in a note is converted either way, and off, the editor is
exactly what it was.
Typing / at the beginning of a line opens the searchable insert menu. It can
make headings, lists, quotes, dividers and code blocks, or enter the existing
image, video and voice-note flows. Table opens a compact grid picker and writes
a plain GFM table in one undoable edit, with its first header ready to replace.
Markdown-only commands say so and ask before enabling Markdown for every note;
they never change that preference silently. On touch devices the footer's +
opens the same menu. A slash anywhere else stays ordinary text, so division and
web addresses never compete with commands.
Hidden markers are laid out at a size too small to take room or be seen, which
the editor's fixed line height keeps from moving the line they sit on; bullets,
boxes and table grids are painted behind the field
(markdown_backdrop.dart) in the room their own characters keep, so a click
still lands where it looks as though it does.
The calculator reads a markdown note the way it is drawn. A list marker is a
bullet, not a minus sign or a running tally; code blocks and struck-through
text are not calculated; emphasis is read for its words, so **5 + 3** is 8;
and a * between two operands is always multiplication, never emphasis, so
2*3*4 and (2+3)*4*(5) stay arithmetic. A long note is parsed in pieces the
parser is guaranteed to read the same way on their own, and only the piece
that changed is read again (markdown_syntax.dart);
test/markdown_syntax_test.dart checks the pieces against a whole-note parse
over thousands of generated notes and edits.
Daily separator timestamps follow the time zone selected in Settings. The default follows the device, while an explicit city uses bundled IANA rules so day boundaries and daylight-saving changes are based on the original edit instant. Existing separators are plain note text and are not rewritten when the setting changes.
The sidebar uses that same time zone for each note's compact updated date and time. Notes are kept newest-updated-first on load and move to the top as soon as they are edited. During a search, the timestamp is temporarily replaced by the matching line so body-only results still have context.
A desktop-width window can put up to three notes side by side. Each pane holds exactly one note, and a note is only ever open in one pane: choosing a note that is already on screen focuses its pane rather than opening it twice. There are no tabs piling up behind a pane.
- The split button in the title bar (⌘\ or Ctrl+\) opens an empty pane beside the focused note, and the next note chosen from the list goes into it.
- A note's menu in the list has Open to the Side; Option-click (Alt-click) does the same.
- A note dragged from the list, or a pane dragged by its title bar, lands beside a pane when dropped on its outer part and in that pane's place when dropped in its middle, swapping with it if the note was already open. The highlight says which before anything moves. With three panes open there is no room beside, so an edge means the pane itself.
- ⌘1, ⌘2 and ⌘3 (Ctrl on Windows and Linux) focus a pane and ⌘W closes the focused one. All of them can be changed under Settings › Shortcuts › Split view. Closing a pane never archives or deletes its note.
A single pane has no title bar, so the editor looks exactly as it did before panes existed. Panes share the row evenly whenever one opens or closes; dragging a divider resizes the two panes beside it, and double-clicking one evens them all out again. The panes, their widths and which one is focused come back at the next launch; an empty pane does not. A window too narrow for the notes list beside a note shows only the focused pane's note, and the rest return when it widens. Only the focused pane reports presence in a shared note, and a recording is delivered before a change would take its note off the screen.
Everything is one JSON file in the platform's application-support directory. No account, no sync, no server.
On iOS and Android, disk lookup is not on the launch critical path. The first Flutter frame is a focused plain-text capture field with no calculator, theme, font, image, or network setup ahead of it. Saved notes hydrate in the background. If someone types before hydration finishes, that text becomes a normal new note during the handoff, without dropping focus or the keyboard.
Writes are coalesced into a 250 ms window and forced out whenever the app is backgrounded or asked to exit, with a write-then-rename so a crash mid-write cannot truncate the file. The in-memory copy is always current, so nothing on screen ever waits for the disk. Large JSON reads and all JSON writes are moved off the UI isolate so a long note history cannot interrupt typing.
Exchange rates are the only networked feature. The cached snapshot is published before the full calculator mounts, so currency maths is available as soon as hydration completes and keeps working offline; a failed refresh changes nothing. Refreshes use Frankfurter's v2 USD rates first and try the ExchangeRate-API open endpoint only when the primary response fails validation. Snapshots are never combined, and their source is cached for accurate attribution. Network client creation and stale refreshes wait until after the editor is ready. Rates then refresh on launch, resume, and every six hours while the app stays open. With no rates ever fetched, currency lines simply stay plain text.
The spec described an Electron build. These changed for Flutter:
| Spec | Here | Why |
|---|---|---|
| mathjs | Purpose-built engine | No Dart equivalent with unit support, and percentages want to be a value type. |
| Layered textarea + mirrored div | One TextField with a custom controller |
Flutter styles editable text natively. |
| Separate highlight tokenizer | The parser's own lexer | Colouring cannot drift from evaluation if it is the same code. |
Regex preprocess() |
Lexer and parser | max(1,250) and 1,250 need context to tell apart; a regex cannot. |
localStorage, write per keystroke |
JSON file, coalesced writes | A synchronous disk write per keystroke is the wrong trade off the web. |
URL ?note=<id> |
Desktop selection plus mobile edit recency | Restores the right note across native app restarts. |
| React Query | RatesRepository |
One cached resource does not need a query layer. |
| Two-pane only | Desktop two-pane; mobile editor with a notes drawer | Note taking stays one tap away on a phone. |
-
macOS — hidden title bar with the traffic lights inset into the sidebar; the toolbar's inert stretch is the window drag region. Sandboxed, with
network.clientadded for the rate refresh. -
Windows / Linux — native caption retained.
-
iOS / Android: accepts text on the first Flutter frame, then opens the most recently edited note when no launch text was entered. A launch draft becomes a new note, unless the app was opened from a widget, which carries it into the note being written instead. Search and the full note list are built only when the hamburger drawer first opens; the gutter is fixed-width and the divider is hidden.
-
The widgets (iOS / Android) — two of them, offering three actions: Write, Dictate and Capture. The square widget is one cell and one action, chosen when it is placed and changed afterwards — iOS through Edit Widget, Android through a small configuration screen — and defaulting to Write, which is what the widget did when Write was all it could do. The wide widget is three cells with all three actions side by side, one tap target each. The square one is also offered to the Lock Screen, and on iOS 18 each action is a control for Control Centre and the Action button.
All of them show an icon and a word and deliberately no note text: a widget that previewed what somebody wrote would have to read the note store, keep itself refreshed against a system budget, and show that writing to whoever picks up a locked phone. Showing only the action costs none of that, and means a widget is drawn once and then left alone.
Every action opens the last note at its end, keyboard up, rather than making a new one — a way back into the notebook, not a way to fill it with one-line fragments, and not something that spends a plan's note allowance on a mis-tap. What each adds on arrival is the rest of it: Capture opens the image picker over that note, and Dictate will start a recording in it once there is a recorder to start (see
docs/voice-notes.mdin the monorepo — until then a Dictate tap is a Write tap, andHomePage._startVoiceRecordingis the one place it will be filled in).The platform reports which action a launch came through over a single method channel,
kapynotes/quick_capture, and answers once: whoever asks first gets it. Dart asks on the way up, and again on every resume, so a tap that reaches an app already running is acted on too. The only Dart that knows islib/core/quick_capture.dart; everything else about the launch is unchanged. Android names its own intent action per widget; iOS openskapynotes://write,kapynotes://dictateorkapynotes://captureand the scene delegate parks it. -
Every platform: Ready to type on open is on by default. The latest note opens focused on a fresh line, and returning to the app starts another append position without saving empty lines. It can be disabled in Settings › General.
-
Shortcuts:
⌘/Ctrl Nnew note,⌘/Ctrl Fsearch,⌘/Ctrl Stoggle sidebar,⇧⌘⌫/Ctrl+Shift+Deletearchive the note. Two more are registered with the OS and answer from inside any other app — which also means every other app loses them while this one runs, so they sit on chords little else uses:⇧⌥⌘X/Alt+Shift+Xsummons the window,⇧⌥⌘N/Alt+Shift+Nsummons it onto a blank note. All of them are rebindable in Settings › Shortcuts. -
Closing the window quits on Windows and leaves the process running on macOS, each platform's own convention. Keep running in the tray (Settings › General, off by default) makes both hide to a tray icon instead, so the system-wide shortcuts go on answering. The icon carries Open, New Note and Quit — the only way out of an app whose close button no longer closes it — and exists only while the setting does. Windows takes a single-instance lock to go with it: an app that looks shut is one whose desktop icon gets clicked a second time, and two copies share one notes file.
macOS hides the title bar so the toolbar can act as window chrome, which means
the OS draws the traffic lights straight over whatever is in the window's
top-left corner. Which widget that is depends on state: the sidebar when it is
showing, the toolbar when it is collapsed. WindowChrome owns the geometry and
each pane asks for the inset it needs — the sidebar is tall enough to start
below the buttons, while a one-row toolbar has to step around them.
On a touch device the same corner belongs to the status bar and the bottom edge
to the home indicator. The compact layout's Scaffold handles its top chrome,
while the editor folds the bottom inset into the padding the text and the
results gutter share — insetting only one would pull them apart.
Both were caught on a real device after the widget tests passed, and both now have tests of their own.
TextField merges the style you give it over the Material text theme, so
any metric property you leave unset — letterSpacing especially — is
inherited. The field then renders wider than the same string measured with
your style alone, wraps somewhere else, and every result below the wrap sits
on the wrong line. EditorMetrics.textStyle pins all of them.
RenderEditable also wraps text inside width - (1px + cursorWidth), holding
that sliver back for the caret. It is smaller than one monospace glyph, so it
only bites for lines that land in the gap — which is exactly what makes it
easy to ship. EditorMetrics.textLayoutWidth applies it to both sides.