第拾肆話
The desktop
Tray icons and the Dock menu, apps in the background, global shortcuts, file dialogs and the file manager, opening files, one instance, quitting, recent documents, starting at login, updates, the displays, power and progress on the app's icon.
What only a desktop has, beyond windows and the menu bar (Navigation): tray icons and the Dock menu, flyouts, apps that live in the background, global shortcuts, panels and launchers, finding the apps installed, click-through windows, file dialogs and the file manager, files the app opens, one instance, quitting, recent documents, document-based apps, starting at login, updates, Now Playing and the media keys, the displays, power, progress on the app's icon and the system's appearance. To follow changes to files and folders (a folder the app shows, a document another app saves), see Watching files, which phones and the web have too.
Each one is a package, or a function in ui or app, and works on the
Mac, Windows and Linux. A phone or a browser that hasn't one answers
that it hasn't (ErrUnsupported, ErrNotAvailable, or Supported()
false), so an app can offer it only where it works.
Tray icons and the Dock menu
tray.Show, called inside a component, puts an icon in the tray while
that component is mounted: an NSStatusItem in the Mac's menu bar, an
icon in the notification area on Windows, a StatusNotifierItem on Linux
(KDE, Xfce, Cinnamon, MATE, Budgie, and GNOME with the AppIndicator
extension).
tray.Show(tray.Props{
Symbol: ui.Val("moon.zzz"),
Title: remaining, // a signal: "25:00" beside the icon
Tooltip: ui.Val("Focus timer"),
Menu: signal.Static([]ui.MenuItem{
{Title: "Start", OnPress: start},
{Title: "Length", Submenu: []ui.MenuItem{
{Title: "25 minutes", OnPress: func() { length.Set(25) }},
{Title: "50 minutes", OnPress: func() { length.Set(50) }},
}},
ui.MenuSeparator,
ui.MenuQuit,
}),
})Its Image or Symbol (a template image on the Mac, which follows the
menu bar's appearance), Title (the Mac, and Linux panels that show
labels), Tooltip and Menu follow their signals, and the icon goes
when its owner is disposed. Without OnClick, a click opens the menu;
with it, a click runs OnClick and a right click opens the menu.
Menus take Submenus and ui.MenuQuit, as ui.MenuBar's do.
tray.DockMenu(items) adds items to the Dock icon's menu on the Mac;
on Windows they're the jump list's Tasks, which reach the running app;
on Linux, the launcher's quicklist.
examples/tray is a timer that lives in the menu bar.
Flyouts
A flyout is a window that hangs from a tray icon, as the panels of the
Mac's menu bar extras and of Windows' notification area do, or from a
rectangle of a window. It's a ui.Window with a Flyout:
open := signal.New(false)
icon := tray.Show(tray.Props{
Symbol: ui.Val("bolt.fill"),
OnClick: func() { open.Set(!open.Peek()) },
Menu: ui.Val([]ui.MenuItem{ui.MenuQuit}), // a right click
})
ui.Window(ui.WindowProps{
Open: open,
Flyout: &ui.Flyout{Anchor: icon.Anchor(), Placement: ui.PlacementBottom},
FitContent: true, MinWidth: 260, MaxHeight: 480,
Content: panel,
})- It has no frame and no taskbar button. On a Mac, with no
Background, it shows the material of menus behind its content, with rounded corners and a shadow, so give its content no background there; Windows 11 rounds it and casts a shadow over the window color; Linux draws neither. Placementis the side of the anchor it goes to and how it lines up with it (PlacementBottom: below, centered). Where the display has no room there it flips to the other side (below a Mac's menu bar icon, above an icon of a taskbar at the bottom of the screen), then slides into the display, then shrinks to fit it.Gapkeeps it off the anchor.Anchorfollows its accessor:ui.Anchor{X, Y, Width, Height}is a rectangle of the window the flyout is declared in, in points from its content's top left (a view's frame, fromOnLayout); there it's that window's child, and moves with it.- It takes the keyboard as it shows, without bringing the app to the
front, and closes as the user closing it does (
Openfalse,OnClose, orOnCloseRequestasked) when they click elsewhere, go to another app, or press an Escape its content leaves. - A dialog over it: a
Modalui.Windowdeclared in its content shows over the flyout, above it and centered on it, and keeps the flyout open until it closes, when the flyout gets the keyboard back. AnAlertover it keeps it open too. FitContentsizes it to its content (below), placed again against its anchor as that grows or shrinks.
| Tray flyout | Placed against | |
|---|---|---|
| macOS | a borderless, nonactivating NSPanel at the level of menus, over the material of menus |
the copy of the status item the user clicked (each display's menu bar has one) |
| Windows | a borderless, topmost tool window with Windows 11's rounded corners | the icon's rectangle (Shell_NotifyIconGetRect), the taskbar's edge considered |
| Linux | an undecorated window, out of the taskbar | under X11, where the panel says the icon was clicked; under Wayland, which lets no app place a window, where the desktop puts it |
| The web | a panel in the page, the page's top right for a tray anchor | a rectangle of its window, in the page |
examples/tray's icon opens one with the timer in it, and a dialog over
it.
Windows sized to their content
FitContent (WindowProps', WindowOptions') sizes a window to what
it shows, and follows it as it changes: a flyout with a list of nodes
that grows, a launcher whose results come and go. Its width is its
content's widest line (max-content) within MinWidth and MaxWidth,
at which its text wraps; its height is its content's at that width
within MinHeight and MaxHeight, beyond which a Scroll in it
scrolls. The core lays the content out at that size as it lays out each
frame, and the window takes it in the same pass, so it shows at its size,
never at another first. A flyout is placed again against its anchor; a
window or a panel keeps its top edge and grows down. The user can't
resize it. On the web the main window is the tab, which fills the page.
Apps in the background
app.KeepRunning() keeps the app running when its last window closes:
the window hides, and ui.MainWindow().Show() brings it back (on the
Mac, so does a click on the Dock icon). app.RunInBackground(), or
asuka.json's "background": true, also starts the app with no
window and, on the Mac, no Dock icon: an app of the menu bar or the
tray. Call them before app.Run.
Global shortcuts
hotkey.Register registers keys that reach the app while another app
is in front, for as long as the component that registers them lives:
k := hotkey.Register(hotkey.Props{
Shortcut: ui.Val(ui.Cmd("space").With(ui.Alt)),
Description: ui.Val("Show the launcher"),
OnPress: func() { ui.MainWindow().Focus() },
})OnPress runs on the main thread at each press, and the shortcut
follows its accessor (keys the user picks in settings). k.Status()
and k.Err() say whether it's active, pending, or why it failed:
ErrTaken (another app has the keys), ErrNotAllowed (the user
declined, or the desktop doesn't know the app), ErrNotAvailable,
ErrKey.
- macOS: Carbon's hot keys, with no Accessibility permission. The
system's own shortcuts count as taken. A press doesn't bring the app
forward:
Focusa window to. - Windows:
RegisterHotKey; the press lets the app take the foreground. - Linux: under X11 the app grabs the keys itself. Under Wayland the
desktop binds them through the GlobalShortcuts portal (GNOME 48, KDE
Plasma 6): it may ask the user to confirm or pick other keys, and it
needs the app installed with its desktop entry. A window
OnPressshows takes the focus with the press's activation token. - Phones and the web have none (
ErrNotAvailable).
Panels and launchers
A panel is the window of a launcher, a command palette or a quick
entry, as Spotlight's and Raycast's are: summoned from anywhere by a
global shortcut, it floats above every app's windows, on whichever
Space or virtual desktop the user is on and over apps in full screen,
stays out of the taskbar, the Dock's windows and the app switcher, and
takes the keyboard without bringing the app to the front on a Mac, so
the app the user was in keeps its menu bar and gets the keyboard back
as the panel goes. It's a window with Panel: a launcher's main
window, in an app that runs in the background:
func main() {
app.RunInBackground()
app.RunWindow(ui.WindowOptions{Panel: true, Hidden: true, FitContent: true,
MinWidth: 640, MaxWidth: 640, MaxHeight: 440}, Launcher)
}
func Launcher() ui.Node {
w := ui.UseWindow()
w.OnCloseRequest(func() bool { w.Hide(); return false }) // Escape, a click elsewhere
hotkey.Register(hotkey.Props{
Shortcut: ui.Val(ui.Shortcut{Key: "space", Mods: ui.Alt}),
OnPress: func() { w.Show() },
})
// ...
}- It has no frame. On a Mac, with no
Background, it shows the material of popovers behind its content, rounded and with a shadow; Windows 11 rounds it; Linux draws it as a window's content. - Without
XandY(or aStateKey), it shows on the display with the pointer, centered across it, a third of the way down, each time it shows;SetPositionandCenterplace it for good. WithFitContentit grows down from its top as results come. - It closes as the user closing it does on an Escape its content leaves
(a text field's too) and as it loses the keyboard: a click elsewhere,
another app. Hide it instead (
OnCloseRequest, above), so it shows again at once, as it was; the main window of an app that keeps running hides as it closes anyway. Showshows it with the keyboard;Focusdoes the same, never bringing the app forward.
| A panel | |
|---|---|
| macOS | an NSPanel with the nonactivating style, on every Space and over full-screen apps (canJoinAllSpaces, fullScreenAuxiliary), out of the window cycle, floating; never the main window |
| Windows | a borderless topmost tool window owned by a hidden window (no taskbar button), Windows 11's rounded corners; it takes the foreground, which a global shortcut's key press lets it |
| Linux | an undecorated window; under X11 a utility window kept above, on every workspace, out of the taskbar and pager; under Wayland an overlay layer-shell surface where the app runs with gtk4-layer-shell (LD_PRELOAD) on a compositor that has the protocol (KDE Plasma, Sway, Hyprland, COSMIC), else a window as others (GNOME places it, and keeps nothing above) |
| The web | a panel over the page, a third of the way down |
examples/launcher is a Spotlight of its own: ⌥Space (Alt+Space) shows
a panel with a search field, the apps installed as you type, with their
icons, and Enter opens the first.
Finding apps
asuka/sdk/apps lists the apps installed, as a launcher searches them:
apps.List(func(list []apps.App, err error) { installed.Set(list) })
results := apps.Search(installed.Get(), query.Get())
apps.Icon(results[0], 32, func(path string, err error) { icon.Set(path) }) // a PNG for ui.Image
apps.Open(results[0], nil)Listgives each app'sName(as the system shows it, in the user's language),IDandPath, sorted by name; it reads the disk off the main thread and answers on it. List them again as the panel shows: an app may have come or gone.Searchranks them for what the user typed, ignoring case and accents: the name itself, then its start, then a word's start or the initials ("vsc" for Visual Studio Code), then anywhere, then its letters in order.Icondraws an app's icon at a size in points as a PNG file in the app's caches, kept for the next time, and gives its path.Openstarts the app, or brings it to the front. Hide the panel first, so the app the user was in has the keyboard until the opened one takes it.
| Where | ID | Icon | Open | |
|---|---|---|---|---|
| macOS | /Applications, /System/Applications, ~/Applications and a folder below each, Finder |
the bundle identifier | the workspace's icon for it | NSWorkspace |
| Windows | the Start menu's apps (the shell's Applications folder), packaged ones too | the AppUserModelID | the shell's image for it | the shell |
| Linux | the desktop entries a menu shows (XDG, Flatpak's and Snap's too), by OnlyShowIn, NoDisplay, TryExec |
the desktop file id | the entry's icon from the theme | GIO, with the display's launch context |
Phones and the web answer apps.ErrUnsupported.
Click-through windows
ClickThrough (or ui.UseWindow().SetClickThrough(true)) lets the
mouse go through a window to what's behind it, another app's window or
the desktop: an overlay, a HUD, a desktop pet, made Transparent and
AlwaysOnTop. ClickThrough() follows it as a signal. The window
takes no clicks, hover or wheel at all while it's on; switch it off to
let the user use it.
ui.Window(ui.WindowProps{Open: hud, Transparent: true, AlwaysOnTop: true, ClickThrough: true,
Width: 240, Height: 80, Content: meters})It's ignoresMouseEvents on a Mac, a layered WS_EX_TRANSPARENT
window on Windows and an empty input region on Linux
(gdk_surface_set_input_region). On the web a window in the page lets
clicks through to the page; nothing lets them out of the browser.
File dialogs
asuka/sdk/dialog shows the system's file dialogs and gives the user's own
paths, not copies:
dialog.Save(dialog.SaveOptions{
Name: "Notes.md",
Filters: []dialog.Filter{{Name: "Markdown", Extensions: []string{"md"}}},
From: view, // the window it belongs to
}, func(path string, err error) {
if path != "" {
os.WriteFile(path, export(), 0o644)
}
})dialog.Open (several files with Multiple, Filters by MIME type
such as image/* or by extension), dialog.Save (a suggested Name;
the dialog asks before replacing a file) and dialog.Folder. Title,
Button, Start (a folder, or a file in it) and From set them up. A
cancel is no path and no error.
On the Mac they're sheets on the window; on Windows the common item
dialogs, modal to the window; on Linux GTK's file dialog, which is the
desktop's portal in a Flatpak. In a browser, Open and Folder copy what's
picked into the app's files, and the file Save gives is written to the
file the user chose (Chrome, Edge) or downloaded. On iOS and Android,
Open is the document picker (copies, as device.PickFiles gives), and
Save and Folder answer dialog.ErrUnsupported.
The file manager
asuka/sdk/shell does what the file manager does with a file:
shell.Reveal shows it selected in its folder (Finder, Explorer, or the
desktop's file manager), shell.Open opens it in the user's app for its
type (shell.ErrNoApp if there's none) and a folder in the file
manager, shell.Trash moves it to the trash or the Recycle Bin and
never deletes it for good (it fails instead), and shell.Beep sounds
the alert. Phones and the web have no file manager: Reveal and Trash
answer shell.ErrUnsupported, and on the web Open shows the file in a
new tab. To show a file's contents in the app, use asuka/sdk/preview.
Opening files
Declare the kinds of files the app opens in asuka.json's fileTypes
(Shipping), and handle them with ui.OnOpenFiles:
ui.OnOpenFiles(func(paths []string) {
for _, p := range paths {
open(p)
}
})Files opened together arrive in one call, as absolute paths: a double click in Finder, Explorer or Files, Open With, a file dropped on the Dock icon, a recent document chosen, and on Windows and Linux the files on the command line. Those the app launched with wait for the first handler. On iOS the path is the document where it is, readable until the app quits; on Android and the web it's the app's own copy in its caches.
One instance
On Windows and Linux an app with an id runs once: starting it again
brings the running one to the front, app.OnSecondInstance(func(args []string, dir string)) gets the new launch's arguments and working
directory, and the files and links among them go to ui.OnOpenFiles
and ui.OnOpenURL. Set "singleInstance": false in asuka.json to run
a new instance each time. The Mac always runs an app once and passes
files, not arguments.
Quitting
app.Quit() quits as the user's Quit does. app.OnQuit(func(e *app.QuitEvent)) runs first, however the quit started (⌘Q, the Dock,
closing the last window, Ctrl+C or SIGTERM, logging out), and
e.Cancel() keeps the app running, to ask about unsaved changes. When
e.SessionEnding is set the quit can't be canceled: save at once,
without a dialog. A window's own close is asked first, through its
OnCloseRequest (Navigation).
app.DataDir, ConfigDir, CacheDir and LogDir are the app's own
folders, made as they're asked for.
app.Relaunch() quits the same way (the handlers can cancel it) and
starts the app again once it has exited: after an update, or a setting
that takes effect at launch.
Updates
update.Window(update.Options{}), in the app's root, is Sparkle's
updater: it checks for a new version soon after launch and once a day,
shows its release notes with Install Update, Skip This Version and
Remind Me Later, downloads it with a progress bar (only what changed,
when there's a delta from the running version), checks that it's signed
with the app's key, installs it and offers Relaunch Now.
update.AppMenu() puts "Check for Updates…" after About on the Mac (in
Help elsewhere). update.Check, Release.Install and update.Relaunch
make an interface of the app's own.
asuka update keygen makes the key pair, asuka.json's updates says
where releases go (GitHub's releases, an S3 bucket or any HTTPS
directory, or a server of yours), and asuka publish --platform mac
(windows, linux) builds, signs and uploads them
(Shipping). A Mac app's new bundle waits for it to
quit; Windows and Linux replace the app's files while it runs. An app
that the system's packages, Flatpak, Snap or an app store installed is
updated by them: update.Supported() says so, and phones answer
ErrUnsupported. On the web, a page older than the site offers to
reload.
Recent documents
Call recent.Add(path, nil) for each document the user opens or saves.
The Mac lists them in the File menu's Open Recent and the Dock menu,
Windows in the jump list, Linux in the user's recent files. Choosing one
opens it through ui.OnOpenFiles. recent.List returns them newest
first, skipping files that are gone, and recent.Clear removes the
app's own.
Documents
asuka/sdk/document makes an app whose windows each show a file, as a text
editor or a drawing app does: what NSDocument gives a Mac app, on every
platform. Describe the kind of document, and run it:
func main() {
document.New(document.Options[string]{
Types: []document.Type{{Name: "Plain Text", Extensions: []string{"txt"}}},
Read: func(r io.Reader) (string, error) {
b, err := io.ReadAll(r)
return string(b), err
},
Write: func(w io.Writer, text string) error {
_, err := io.WriteString(w, text)
return err
},
Equal: func(a, b string) bool { return a == b },
View: func(doc *document.Document[string]) ui.Node {
return ui.TextField(ui.TextFieldProps{Value: doc.Content(), Multiline: true, Plain: true})
},
}).Run()
}List the same types in asuka.json's fileTypes, so the system offers
the app for them. examples/textedit is this editor with a status line
and a bar for a file another app changed.
A document's Content() is a signal: the view binds it, and changing
it edits the document, unless Equal finds it equal to the file's
(undoing back to it, typing a letter and deleting it). Reading the file
again sets it too. A content the app changes in place (a pointer to a
model) says so with doc.Changed(), and gives Snapshot, which copies
it before it's written. doc.State() is a signal of the document's
name, file and whether it's edited, read-only, in conflict or deleted.
What the user gets on the desktop:
- A window per document, named after its file ("Untitled",
"Untitled 2" before it has one), opening where the user left that
file's window. A Mac shows a dot in the close button while it has
unsaved changes and the file's icon in the title bar; Windows and
Linux a
*before the title, which ends with the app's name, and GNOME the file's folder under it. - The File menu: New, Open…, Open Recent, Close, Save, Save As…,
and on a Mac Duplicate, Rename…, Move To… and Revert to Saved
(Revert elsewhere), with their shortcuts, acting on the window in
use.
Options.Menusadds the app's own menus beside it; one titled File adds its items to it. Windows and Linux show the menus in each document's window. - Autosave: a document with a file is saved a moment after the
user stops typing, when its window loses the focus and when it
closes, as Mac apps save; it never asks.
AutosaveOffsaves when the user says, and asks when the window closes, as Windows apps traditionally do. - Questions only where saving can't happen by itself: closing an
untitled document ("Do you want to keep this new document?"), one
whose file is read-only or another app changed ("Save Anyway",
"Revert"), or any edited one with
AutosaveOff. - Recovery copies: unsaved changes are copied into the app's data a
second after the user stops typing. A crash, a forced quit or a quit
loses nothing: the next launch opens those documents again, edited.
Quitting keeps the documents that way, as VS Code does; with
AskOnQuitit asks instead (on a Mac, "You have 3 documents with unsaved changes. Do you want to review these changes before quitting?"; on Windows and Linux one question naming them). When the session ends there's no time to ask: everything is saved or kept at once. - Files opened from outside: Finder, Explorer, the Dock, the command line, Open Recent, and Open… go to the document's window if it's open already.
- Files other apps change: an unedited document reads its file again; an edited one asks at its next save. A file renamed is followed (on a Mac, wherever the user moves it on its disk), and one deleted is written again by the next save.
A Mac's document app stays open with no window, and clicking its Dock
icon makes a new document; on Windows and Linux the app quits with its
last document's window. NoUntitled opens nothing at launch instead of
an untitled document.
On a phone and in a browser there's one document at a time: the app opens on a list of its documents (on iOS, its folder in Files), with New and Open…, and a document's screen is pushed over it. Each document has a file from the moment it's made and is saved as the user edits, when the app goes to the background and when the user goes back, as UIDocument's apps save: nothing ever asks. Files from elsewhere are copied into the app's documents first.
The questions and the menu speak the app's language (English, German,
French, Spanish, Japanese and Chinese so far): give others, or change
them, with keys document.<key> in the app's catalogs.
Left out, of what only NSDocument does on a Mac: versions (Revert To a version), locking, renaming in the title bar's popover, and documents that are folders.
Starting at login
Offer login.Set(true, done) as a setting, and read it back with
login.Enabled. It's a login item on macOS 13 and later (a packaged
app only; login.ErrNeedsApproval until the user allows it in System
Settings), a Run entry on Windows, and an autostart entry on Linux (the
Background portal in a Flatpak). login.OpenedAtLogin tells a launch at
login apart, so the app can start out of sight.
Now Playing and the media keys
nowplaying.Show puts what the app plays in the system's media
controls, and their buttons and the keyboard's media keys (play/pause,
next, previous) reach its handlers: Control Center's and the menu bar's
Now Playing on the Mac, the media controls of the volume flyout and
quick settings on Windows (the app window's
SystemMediaTransportControls), an MPRIS player on Linux, which GNOME's
and KDE's controls and playerctl drive. It's the same package as on
phones, where it's the lock screen's controls
(Device and services):
music := audio.Load("music/spring.mp3")
signal.OnCleanup(music.Release)
nowplaying.Show(nowplaying.Props{
Title: ui.Val("Spring"),
Artist: ui.Val("Vivaldi"),
Player: music, // its state and position; play, pause, seek on it
OnNext: playNext,
})Displays, power and the app's icon
screen.UseDisplays() follows the displays: where each one is, its
work area, scale and refresh rate, and which is primary; Nearest and
Matching pick the one under a point or a window. Wayland has no work
area: it's the display's bounds there.
power.Use() follows the battery, Low Power Mode and the thermal state.
power.OnSuspend, OnResume, OnLock and OnUnlock say when the
computer sleeps and wakes and when the screen locks, and
power.IdleTime() how long the user has been away. Phones have lock
and unlock, but no suspend or idle time; the web, the battery alone.
dock.SetProgress shows a task's progress on the Dock icon, the
taskbar button, or a Linux dock that has the Unity launcher API (GNOME's
own dash shows none). dock.RequestAttention bounces or flashes the
icon while the app is in the background. The count on the icon is
notify.SetBadge.
appearance.Use() gives the system's accent color, Increase Contrast
and Reduce Transparency, for what the app draws itself; the views Asuka
shows follow them already.