desk

package module
v0.0.0-...-afbb6e4 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 4, 2026 License: BSD-3-Clause Imports: 38 Imported by: 0

README

go-xrkit

go-xrkit/desk

CI Go Reference coverage license

Several computer screens, floating on a 360° ribbon inside AR glasses, scrolled from the keyboard.

Pure Go, CGO_ENABLED=0, no vendor SDK.

What it is for

XR glasses show one screen. This puts a ring of them around you: real virtual displays that macOS extends the desktop onto, so ordinary applications run on them, captured and drawn as FLAT screens on a band at eye level. The keyboard turns the band; one key promotes a screen to fill the view.

One screen is one full view. Each virtual display is created at exactly one eye's resolution — the most the glasses can show at once — and is given exactly the arc that eye can see. Looking straight at a screen shows it edge to edge, at one source pixel per output pixel.

How many screens is a choice, and it stops at nine. Nothing in the geometry supplies a ceiling: the screens are flat, so the angles are only a scroll coordinate and the band is as long as it needs to be — the plan would spread forty over the turn without complaint. So the limit is decided, at desk.MaxScreens = 9, where three things agree: the gallery is three columns wide, so nine fills three rows of three exactly and every screen keeps its column as the desk grows; each screen costs a display to create and a stream to capture, linearly; and past nine a person stops holding a map of where anything is, which is the whole point of a fixed arrangement. A bigger number is clamped when a program composes a plan and refused, with the ceiling named, when it comes from a settings file — a clamp is right for code and wrong for a line somebody wrote.

The field of view is reported, not required. What fills the glasses is one source pixel per panel pixel, which needs the panel's resolution and nothing else, so a headset nobody has heard of runs at the right size and an unknown field of view prints as unknown rather than as 0.00°.

3D on anything the glasses are showing

-3d, or "3D on" in the menu, turns whatever is on the ribbon into a stereo pair — a browser, a terminal, a film in a window. It is a toggle, not a mode: the picture is flat until it is asked for, and flat again the moment it is turned off.

With -depth-model naming a Core ML depth model, the depth comes from a real network on the Neural Engine and both views from compute kernels on the GPU (go-xrkit/depth3d) — about four tenths of a millisecond of processor time a frame, which is what leaves the rest of the machine to the desk. Without one, depth is guessed from the picture itself: it needs nothing, and is visibly worse. The log says which.

It is refused, with a reason, on a display showing one eye — there is nothing to convert to. And a frame the converter refuses falls back to the flat picture rather than to black: a viewer who sees the depth go away knows what happened, and one who sees nothing thinks the desk has crashed.

The depth is invented. A captured screen has none of its own, so what this puts in front of each eye is a guess — a good one on a photograph or a film, and a confident one about a page of text. That is why it is a switch and not a default.

Why the keyboard

The Beast, the Luma Ultra and the XREAL One series all do their 3DoF anchoring inside the glasses, which is also why their motion sensors are not offered to the host. So head movement is theirs and the ribbon is yours: the two compose instead of fighting. Turning the band is a key press, and it is deliberate.

The keys

In the window, and in the glasses:

key on the ribbon in the screen gallery in the application gallery
turn the band move the selection — it wraps, because the ribbon is a circle move the selection, the same way
nothing: a band has no rows move a row — and stay in the column, so a column that ends, ends move a row, the same way
Enter go to the highlighted screen, the short way round put the highlighted application on the screen the band is showing
Space / f fill the view with the focused screen
g show every screen at once put the ribbon back exactly as it was
a show what is RUNNING put it away
x one application per screen, in order the same
Tab / c show the next source here (⌃⌥⌘C from anywhere)
Escape / q quit quit — a mode you cannot leave is a trap quit

The two galleries answer different questions. The screen one is which desktop am I looking at; the application one is what is open, and where is it — a grid of everything with a window, each tile saying which screen it is on. The band keeps its focus underneath it, and that is what makes Enter mean "here": no number to type at a picture you may not be able to see.

x hands out one screen per application, in order, up to the ribbon's count. Anything past the last screen is left where it is rather than wrapped onto a screen that already has one: two windows on one screen hides one of them, and a person who pressed one key cannot be expected to guess which.

Placing an application needs the Accessibility grant, and says so plainly when it has not got it. It goes through the same code as a place block in the settings file, so the live path and the start-up path cannot drift apart.

And from anywhere else

The point of a desk in glasses is that you are using the screens on it. So the keys are claimed system-wide and work while another application has the keyboard — which is not a convenience here but the whole design: the desk's own window is deliberately passive and never takes the keyboard from what is running on the screens.

⌃⌥⌘1⌃⌥⌘9 straight to that screen, from wherever you are
⌃⌥⌘0 fit: one screen, the largest these glasses can show it
⌃⌥⌘← ⌃⌥⌘→ turn the band
⌃⌥⌘↑ show the SCREENS
⌃⌥⌘↓ show what is RUNNING on them
⌃⌥⌘↩ choose: the highlighted screen, or the highlighted application onto the screen in front
⌃⌥⌘A the same, for a keyboard where it arrives
⌃⌥⌘X one application per screen
⌃⌥⌘⇥ what this screen shows — including a mirror of the Mac's own display
⌃⌥⌘M bring the pointer to the screen being looked at
⌃⌥⌘- ⌃⌥⌘= move the band away, and back
⌃⌥⌘[ ⌃⌥⌘] flatten the screens, turn them
⌃⌥⌘S the settings
⌃⌥⌘⎋ quit

Every one of these can be MOVED from desk.hcl, except quit — a key taken from the whole machine is a key taken from whatever you were using, so the default layout is a choice rather than a law. Quit stays put because it is the way out of a desk that covers a display, and somebody wearing glasses cannot see the menu bar.

shortcut "gallery-open" { keys = "ctrl+alt+cmd+G" }
shortcut "further"     { keys = "ctrl+alt+cmd+Up" }
shortcut "fit"         { keys = "ctrl+alt+cmd+Equal" }

Equal, not =: the separator between the parts is -, so Minus is written as a word and Equal follows for the pair to read alike. A name this file does not know is refused at start-up with the list of the ones it does, rather than leaving you pressing a key that does nothing.

⚠ And a LETTER can be swallowed. ⌃⌥⌘A was granted without complaint and never delivered on the machine this was written for: an application's own menu key is invisible to everything, and nothing can detect it. An arrow is a key nothing else claims quietly, which is why the galleries are on them by default.

Nine digits and not ten, because nine is the most screens a desk carries: there is a key for every one and none spare. All nine are claimed whatever the desk holds today — the count changes while the session runs, and a key for a screen that is not there says how many there are rather than doing nothing.

macOS only, for now: this goes through Carbon's RegisterEventHotKey, which asks for no permission at all — no accessibility prompt, no input monitoring. On Linux and Windows the keys work in the window and the run says so. -no-global leaves them alone.

They are not always the keys you get. xrdesk falls back — Shift, then Control, then both — and prints whatever it landed on at start-up, because it has to: of the three ways a shortcut can already be taken, two are detectable and one is not.

The third one is why the applications are on an ARROW. ⌃⌥⌘A was granted without complaint and never delivered a single press: an application's own menu key is invisible to every check there is, so the only symptom is a key that does nothing. Arrows are not claimed quietly. An application's own menu key is invisible to everything. The band was on ⌥⌘←/⌥⌘→ until somebody who had learnt the desk pressed ⌃⌥⌘← and got nothing: one prefix for all of them is worth more than two keys saved. Those two also register without complaint and are also Safari's tab navigation — while xrdesk runs, it wins them, and Safari quietly stops seeing them. That is the trade a global shortcut is; it is printed rather than hidden.

The mouse does not change screens; the keyboard does

A person wearing display glasses sees one screen. The desktop under the pointer is several, most of them invisible, and a pointer that can leave the visible one is a pointer that can be somewhere its owner cannot look.

That was tried the other way round three times over — the band followed the pointer, the pointer wrapped round the ends of the band, a display nothing was showing was fetched onto the screen in front of the viewer when the pointer wandered onto it — and the report after every one of them was the same: « j'ai encore perdu la souris ». Each mechanism answered a hole left by the one before, and none of them answered the shape of the problem.

So the pointer stays. It is held to the screen the band is showing, every frame, and ⌃⌥⌘← ⌃⌥⌘→ are the way to another one — which brings the pointer with them, to the middle of the screen that has arrived rather than to whichever edge a clamp would have dragged it to.

It is held to what a position shows, not to what this program made: a position mirroring this Mac's own panel is how somebody reaches their real desktop, and the pointer has to be able to live there. A position showing nothing, or a display the machine will not measure, is not a fence at all — the pointer is left alone rather than held against a rectangle nobody knows.

The Mac's own screen, in the glasses

Screen 1 is this Mac's own screen. Somebody wearing the glasses still has a Mac in front of them, with a menu bar, a Dock and whatever was already open on it; reaching it should not mean taking the glasses off, and it should not mean knowing a key. So one virtual display FEWER is made and the position it would have taken goes to the machine's own screen — mirror = false in the ribbon block turns that off.

Every other position can show one too: they are all in the sources list, and ⌃⌥⌘C walks through it.

And the pointer goes with it. A position showing this Mac's panel is a screen of the band like any other, so the pointer is held to it while it is the one in front of you: that is how somebody in the glasses reaches their real desktop, with the menu bar and everything on it.

Measured, end to end, with the glasses on:

backlight of display 1:  0.41 → 0.00 → 0.41 when the desk stopped

Not on ⌃⌥⌘Tab, which is where the key used to be. A whole session with the glasses on logged arrows, the gallery and the menu bar, and not one cycle: macOS keeps Command-Tab for its own application switcher whatever else is held down with it. The registration succeeds and the key never arrives — the third kind of conflict, undetectable from here.

When that happens, the panel itself is turned off and lit again when the copy leaves the ribbon or the program stops. It is not tidiness. A person wearing the glasses is looking at the copy; the panel is a second, brighter copy of private work at reading distance, facing whoever walks past, lit at full power for nobody.

Turning the backlight off is not the same as covering the screen with a black window: the framebuffer is untouched, so the picture ON the ribbon does not change, there is no window for the capture to exclude and no stream to rebuild, and nothing another program can raise itself above. It goes through go-macos/brightness, whose Dim reads the level BEFORE changing it and hands back the way home — which is deferred here before anything is turned off, so every way out of a session goes through it.

Three screens are never darkened:

  • a display this program made, which has no panel behind it;
  • the display the desk itself is on — a ribbon position can be pointed at the glasses' own display, and darkening that one would black out the thing being looked at, with the key that undoes it now invisible. It is identified by its rectangle: two identical monitors are the same size and are not in the same place;
  • every screen, when the desk is running in a window rather than in the glasses, because then the desk is a window on one of them.

-dim=false leaves every screen lit.

And a panel this program turned off is put back even when the run that did it never gets the chance. It writes down what it is about to darken BEFORE darkening it, and the next start reads that note and lights the panel again: nothing runs after a SIGKILL, so the screen is dark until then -- there is no way around that -- but it is one start away from being right rather than a setting somebody has to find in the dark. A panel that is ALREADY brighter than the note says is left alone, because somebody turned it up in the meantime.

And nothing holds a backlight off after the desk stops. Two ways out were missing and both were found the hard way, in one session:

  • a panic in the frame loop is a panic in another goroutine: it kills the process without running the deferred call that puts the backlight back. That happened, and the report that came back was « j'ai débranché les lunettes car j'avais perdu l'accès et il n'y avait pas l'icon dans la tray pour que je coupe l'application » — the icon was there, on a panel that had been turned off. A crash now stops the desk the ordinary way, with the stack logged, so every restore runs;
  • unplugging the glasses left it drawing for nobody while holding the backlight off and six displays that do not exist. The desk looks every second for the screen it is on, and stops itself the moment it has gone -- « quand on débranche les lunettes il faut rallumer l'écran », and stopping is what does that.
A screen takes the shape of what it shows

A screen of the band is a whole view of the glasses, so they are all one shape — until a position mirrors a display this program did not make. This Mac's panel is 2056x1329, a ratio of 1.547 against the band's 1.778, and asked for a 1920x1080 frame of it ScreenCaptureKit letterboxes: measured, 124 flat columns down each side. Those bands are then part of the picture, on a screen that cannot be told to drop them.

Changing the Mac's resolution does not help, which was worth measuring before building anything: its sixty display modes are 1.547 and 1.600 and nothing else. There is no 16:9 mode to put a MacBook panel into.

So the screen takes the shape instead — same height as its neighbours, narrower — and the ribbon already knew how to place that: a screen's span on the circle comes from its aspect ratio, which is what keeps its pixels square. The capture is asked for at the source's own shape (1670x1080, 0 flat columns), the band gives that screen its own arc, and the gallery draws it narrower inside a cell of the usual size rather than stretching it across one.

It is driven by the PIXELS, not by whoever opened the capture: what a position shows changes while the desk runs, and the shape arrives with the first frame.

A band that cannot hold the shape says so and stays as it was — four screens of the eye's own shape already take 343° of the available 360°, so a wider screen often does not fit at all.

This is also where a crash came from, with the glasses on:

panic: slice bounds out of range [:6684] with capacity 6680

6680 is 1670 pixels of BGRA. A turned panel gathers its columns one at a time out of the source row, and the fan was still using the band's 1920. The fan now reads each screen's own width — and Canvas.Slant treats a column outside the source as background rather than as a place to read from, because a renderer that can be crashed by a source of the wrong size will be.

How it is put together

go-xrkit/xrkit glasses names the headset; ribbon places screens on the band and composites them by yaw
go-macos/virtualdisplay creates the displays macOS extends onto
go-macos/screencapture streams their pixels
go-widgets the window, and every pixel of interface

There is no distortion table, and no panorama. The screens used to be wrapped on a cylinder and unwrapped again through a per-pixel warp, which cost 2.8 ms of a 16.6 ms frame to make something whose whole purpose is to look flat — and drew the screen you look at straight on with a bow in it, arguing with the depth the glasses already present. Worn once, that settled it. A yaw is now what it looks like: a horizontal offset into a band of flat pictures, which costs nothing.

The screens only exist while it runs

macOS lists them, by name, as XR desk 1XR desk n — in System Settings ▸ Displays, in system_profiler SPDisplaysDataType, and to anything else that enumerates displays. They sit to the LEFT of the main screen, at negative x, in ribbon order.

They are created at start and removed at exit, deliberately: a virtual display that outlives its process is a display somebody has to remove by hand. So an empty display list means one of two things, and the program now says which:

  • it is not running — nothing was created, nothing remains;
  • it never got that far — with no display to show a desk on, xrdesk stops before creating anything, and says so:
glasses: no display matches "VITURE Beast"; attached: "Built-in Retina Display" 2056x1329 (primary)
nothing was created and nothing on this Mac was changed.
  the virtual screens exist only while xrdesk runs, and only alongside a display to show them on
  plug the glasses in, or name one of the displays above with -screen

Releasing a display is asynchronous. virtualdisplay.Close returns in microseconds; macOS keeps listing the display for up to 1.9 s (six of them, macOS 26.6.2). Screens.Close therefore waits — measured, not slept — so that "released" and "gone from the list" are the same moment for anything that looks next: the settings phase, which gives the screens back before opening its window, or a person reading System Settings straight after quitting. Without that wait, six screens that were already dead still read as a leak; the integration test fails naming each one if the wait is removed.

Which glasses are these

Two questions, and they are not the same one: is a headset attached, and which display is it.

The bus answers the first. deskcheck opens with it:

bus
  3318:043e "XREAL 1S" -> XREAL 1S (USB product)

displays
  "Odyssey G95NC" 7680x2160 (primary)

That is a real reading. A headset can be plugged in, powered and enumerated with no DisplayPort lane at all — a port carrying USB 2 only, or one whose bandwidth a large monitor has already taken. It is then present in every sense except the one that shows a picture, and no glasses and no video are different problems that deserve different words.

A USB product id is also the stronger evidence of the two. A display name is whatever the panel put in its EDID, and a dock, a capture card or a KVM in the path can replace it with something generic; a product id names one model, and for some brands it is the only thing that does.

But the bus never says which display the headset is, so the evidence is applied only where something ties it to one: you named the display, or the display names a headset itself and the bus is only saying which one. Otherwise it is reported and not applied. Lending a headset's optics to a monitor renders everything, in the wrong place, with no symptom.

Reading it opens no device and needs no permission — macOS asks IOKit for what the kernel cached at enumeration, Linux reads three files under /sys/bus/usb/devices. Windows and Android fall back to the display name.

Tested against real hardware

This section says what was actually connected to a machine, and what was only read off a specification sheet. The difference matters: a field of view taken from a data sheet renders everything in the wrong place if the data sheet is wrong, and nothing about the picture says so.

Hardware What was actually done
Apple M4 Max, macOS 26.6.2 (build 25G83) everything below
VITURE Beast connected over DisplayPort, observed presenting a 3840x1080 side-by-side 3D mode and a 1920x1200 2D mode, and rendered to
VITURE Luma Ultra enumerated over USB only35ca:1104 "VITURE Luma Ultra XR GLASSES". Connected to this machine on 2026-08-26 alongside the XREAL One S, and like it produced no display at all: its display name and modes remain unconfirmed
XREAL One S enumerated over USB only3318:043e "XREAL 1S", identified by product id, on three different ports across two buses with the XREAL cable. DisplayPort alternate mode never engaged: it negotiated USB 2.0 alone and no second display ever appeared at any layer, so its display name, its modes and its rendering are unconfirmed. A VITURE Luma Ultra on the same machine failed the same way, and the system log knew of exactly one DisplayPort connection throughout — the monitor. Two brands failing identically points at the machine, not at either headset
Samsung Odyssey G95NC, 7680x2160 used as the working display; it is a genuine 32:9 panel and therefore exercises the one case no arithmetic on panel size can classify
Virtual displays six created at once at 1920x1080, each coming up at exactly the requested size, all removed, the active display list returning to precisely what it was

Not yet proven on hardware: capture of a whole display on macOS, which is blocked on a Screen Recording permission that could not be granted on the machine this was built on; capture of an application window IS proven. Every other pair of glasses in the catalogue is there from published specifications only, each with its source URL on the entry.

Send us hardware

We will gladly add and verify a model we can hold. If you want a device supported, or a figure confirmed rather than quoted, send us the hardware and it will be tested against and listed here. Until then, an unverified entry says so.

Platforms

The geometry, the plan and the compositor are portable and have no operating system in them at all. What differs per platform is capturing pixels and, where it is possible at all, creating displays.

Capture Virtual displays
macOS ScreenCaptureKit yes, via private CoreGraphics
Linux X11, and Wayland where the compositor offers wlr-screencopy no — capture the displays you have
Windows in progress — enumeration must be added to go-mswin/win32 first no — an indirect display driver needs signing
Android MediaProjection no — settled; the ribbon carries the phone's own screen

Android is a different shape from the others. Android hands no drawable surface to a process that is not the app, and every path to one is behind JNI, which needs cgo. So an Android build is two processes — a small Java host owning the Activity and the Surface, and an ordinary CGO_ENABLED=0 GOOS=android Go binary speaking to it over a socket, with pixels through a shared buffer. That is the pattern go-widgets/android already proved with a real installable APK, and it is the one the capture follows.

On Android the ribbon carries the phone's own screen and our content, not a set of desktops. That is not a gap to be closed later: Android 15 was asked directly, and refused four different ways. An app-created virtual display comes back without FLAG_TRUSTED, and launching anything onto it — even the app's OWN activity — is a SecurityException. Asking for VIRTUAL_DISPLAY_FLAG_TRUSTED wants ADD_TRUSTED_DISPLAY, whose protection level is signature; the flag is not in the public SDK at all. VirtualDeviceManager needs CREATE_VIRTUAL_DEVICE, which is internal|role and not grantable even by signature.

What DOES work there, and is worth knowing: on the display the glasses themselves provide — public and trusted, as any real external display is — an ordinary app may place other applications with ActivityOptions.setLaunchDisplayId. The refusal is about MAKING a display, not about using the one you were given.

Where virtual displays are not available the ribbon carries the real displays and windows instead. That is fewer screens, and everything else is identical.

Building it

go build ./cmd/xrdesk

Nothing else to know, and that took a change in another repository to be true: the item and its menu are go-widgets/tray's, whose native backends were behind a tray_native build tag so the package could keep a single coverage figure at 100%. A program built the obvious way got a tray that quietly did not exist. tray v0.6.0 links them by default and gates its coverage by file shape instead.

That item is also what holds the platform's run loop while the desk waits for a pair of glasses. Nothing is drawn — and no menu is ever opened — without one, and the window that will own a loop needs a display that is not there yet. So the tray runs the loop and the waiting happens beside it, which is the way round tray is built for: Run when a program has no loop of its own, Attach when it does.

This was learned the long way. The item was mine for a while, built straight on NSStatusItem, and I lent AppKit slices of the main thread to keep it alive. That drew the icon and never opened its menu, because a menu is not drawn, it is tracked, and tracking needs the loop running rather than sampled — measured by clicking the item and counting what appears below the bar: 0 pixels with the slices, a menu with the loop.

Tests

go test ./...

The geometry, the plan and the compositor have no operating system in them, and they are held at 100 % statement coverage, gated in CI. The gate selects files by SHAPE rather than by a list of names -- everything that is not a platform file (Go's own _darwin/_linux/_android/_windows/_js/_other suffixes), not a command, and not a _display.go -- so a new portable file is gated the day it is written instead of the day someone remembers to add it.

Playback needs a display, a video file and a pair of glasses, none of which a runner has, so a total-coverage figure would be a number chosen to pass rather than a standard. The _display.go files are named, not listed, so the exemption is visible in the file name instead of buried in CI, and they stay deliberately thin -- wiring pieces that ARE covered.

One test makes REAL displays, so it is behind a build tag and an environment variable — a runner has no window server, and a display left behind would appear on somebody's desktop:

XRDESK_INTEGRATION=1 go test -tags integration -v -run Integration ./...

It reads the SYSTEM's display list, not ours: six screens appear there under the names macOS shows, and are gone by the time Close returns. Remove the wait in Screens.Close and it fails naming each screen still listed — which is how the wait was shown to be doing something.

And a bench that breaks it on purpose

Tests say what functions do to values. Every defect this program had in a week was about what a session does to a machine, and every one of them was found by somebody wearing the glasses and saying so.

cmd/deskchaos is the other half. It runs real sessions under real faults — one left to finish, the glasses unplugged mid-session, killed outright the way a crash does, a second headset plugged in — and then asks what is still true of the machine that should not be:

  • a display outliving the process that made it;
  • a backlight left dark — and, for a killed session, whether the next start puts it back, because nothing runs after SIGKILL;
  • the desk's own screen on its own band;
  • a panic;
  • a session that opened its window and never drew a frame;
  • a session whose screen went and never said it was stopping.

It also watches while the session runs: a session that misbehaves for ten seconds and tidies up on the way out leaves nothing behind at all, which is exactly what a pointer nobody can find looks like.

It needs no glasses. A virtual display named from the catalogue is a headset as far as this program can tell, so the bench makes its own. The only thing it cannot exercise is the optics.

deskchaos -take-the-machine -rounds 20 -budget 90m -report bench.json

It refuses to start without -take-the-machine, because sessions warp the pointer sixty times a second, turn a backlight off, make and destroy displays and move applications between them — and this kills them halfway through on purpose. .github/workflows/nightly-bench.yml is that, on a Mac kept for it.

One thing it taught about Macs rather than about the desk: after several hundred displays made and destroyed in a day, the window server refuses new ones for the best part of a minute and takes as long to let old ones go. Rounds after that report the machine, not the program, so the bench tells the two apart and says skipped rather than counting it.

Licence

BSD-3-Clause.

Documentation

Overview

Package desk shows several captured computer screens floating on a 360° ribbon inside AR glasses, scrolled from the keyboard.

Index

Constants

View Source
const (
	MinAspectNum, MinAspectDen = 1, 4
	MaxAspectNum, MaxAspectDen = 8, 1
)

The shapes a screen is allowed to take, as fractions of its height: from a tall panel on its side to an ultrawide. A source outside that is not a shape somebody chose, it is a capture that has gone wrong, and giving the band a screen a hundred times too wide would leave nothing else visible.

View Source
const (
	// SettingsWidth is how wide the window is. Its HEIGHT is measured, not
	// declared -- see settingsSize.
	SettingsWidth = 560
	// SettingsPadX and SettingsPadY are the window's margins.
	SettingsPadX = 16
	SettingsPadY = 12
	// ButtonBarH is the band the Save/Close row keeps at the bottom. It is a
	// BorderLayout region, so the content above it can never reach into it --
	// which is what a fixed-height run of fields used to do when the window was
	// made smaller: the buttons ended up drawn over the text.
	ButtonBarH = 40
	// ButtonW is one button.
	ButtonW = 96
	// ControlW and ControlH size the trailing control of a
	// settings row. A SettingRow right-aligns a control at the size the control
	// carries, so these are given to the widget rather than to the row.
	ControlW = 220
	ControlH = 28
	// SwitchW and SwitchH size a switch, which is wider than it is tall.
	SwitchW = 44
	SwitchH = 24
	// PageSpacing is the gap between the cards on the page.
	PageSpacing = 12
	// GlassesIconPx is the drawn headset in a glasses tile. Bigger than a
	// toolbar icon, because the tile is the thing being chosen rather than a
	// decoration beside a word.
	GlassesIconPx = 40
	// GlassesTileW is the floor on a tile's width, in logical pixels: enough for
	// the longest model name in the catalogue rather than for the icon.
	GlassesTileW = 150
)

Sizes of the settings window and its controls, in LOGICAL pixels: everything here goes through toolkit.Scaled at use, so one knob magnifies the whole window and nothing is a device-pixel constant.

View Source
const (
	TraySymbol = "visionpro"
	TrayLabel  = "XR desk"
)

TraySymbol is the system symbol the item shows in the menu bar, and TrayLabel is what a screen reader says about it.

A SYMBOL RATHER THAN AN EMOJI, and it was measured rather than argued. In the item's own strip of the menu bar:

emoji title 👓            79 pixels of ink
symbol eyeglasses        100
symbol rectangle.3.group 161
symbol visionpro         182
symbol display           206

"the glasses icon in the tray is not very legible" is what the first of those looks like to somebody using it: a title is TEXT, so an emoji arrives at the height of a lowercase letter, flat in the bar's own ink, among twenty other items. A symbol is vector, aligned to the bar's cap height, and takes the bar's appearance in every theme.

visionpro rather than the darkest of them: a headset says what this program is, where a monitor says what everything else is. eyeglasses is the shape of the old icon and half the ink, which is the trade this was reported for.

View Source
const AppIconPx = 48

AppIconPx is the SMALLEST an application tile's icon may be, in pixels.

The size actually used comes from the height of the picture — see appsIconPx — because a tile read at arm's length through a pair of glasses is not a tile read on a monitor. This is the floor under that, for a picture small enough that a twelfth of it would be nothing.

View Source
const AwaitPoll = time.Second

AwaitPoll is how often Await asks again.

A second, because that is under the time it takes to look up after pushing a plug in, and because listing displays is cheap. Measured: a display created halfway through a process with no NSApp running appeared in the list within 500 ms, so a second is a poll that cannot miss it and not a poll that is waiting on a cache.

View Source
const DefaultBadgeSeconds = 1.5

DefaultBadgeSeconds is how long the screen's number stays up after the band moves.

Long enough to read without looking for it, short enough that it is gone before it becomes part of the picture. Zero seconds turns it off.

View Source
const DefaultColumns = 3

DefaultColumns is how wide the gallery is when nobody says.

Three, whenever three columns hold every screen in three rows or fewer — which is exactly the three, six and nine a desk is usually built from. A FIXED width is the point: a screen keeps its place in the grid as others are added, so the map a person builds of where things are survives the desk growing. Past nine it stops paying, and the shape is chosen by what leaves the screens biggest instead.

View Source
const DefaultFOVDeg = 45

DefaultFOVDeg is the field of view assumed when the headset's is not known.

The plan reports a field of view and does not require one, on purpose: what fills the glasses is one source pixel per panel pixel, which needs a resolution and nothing else. But the moment screens are TURNED, the angle matters -- it decides how much a panel off to the side is stretched by a flat projection -- so a number is needed and this is the one.

Forty-five degrees, which is close to what the headsets in the catalogue actually have (44.9° for a Luma Ultra, 45.6° for a Beast). Getting it wrong does not move the screen in front of the viewer by a pixel; it makes the ones beside it slightly more or slightly less stretched than they should be.

View Source
const DefaultGapPx = 48

DefaultGapPx is the band the gallery leaves between two cells, in pixels.

The ribbon needs no such number — the space between two screens is whatever the ribbon left there — but a grid is a fold this package invents, so the space in it is this package's to choose. Wide enough that two cells read as two screens rather than as one wide one.

View Source
const DefaultNoticeSeconds = 2.5

DefaultNoticeSeconds is how long a notice stays up when nothing takes it down. Long enough to read a short sentence, short enough not to become part of the picture.

View Source
const DefaultScreens = 6

DefaultScreens is how many virtual screens a desk gets when nobody says.

Six: two rows of three in the gallery, which is a shape a person keeps a map of, and enough screens that the band is worth turning. It used to be "as many as fit round the circle", which was a real limit when the screens were curved and is a fiction now that they are flat.

View Source
const DefaultSplayDeg = 20.0

DefaultSplayDeg is the angle a desk gets when nobody says: twenty degrees.

Enough to read as turned -- the keystone is visible, the neighbours face you -- and shallow enough that a screen two along is still squarely in front of you when you turn to it. It is also close to what people set real monitors to, which is the only evidence available for a number like this.

View Source
const DistanceStep = 0.25

DistanceStep is how much one press moves the band.

A quarter, so the near end and the far end are twelve presses apart: enough that a person can stop where they want, few enough that they can get there.

View Source
const EnvConfig = "XRDESK_CONFIG"

EnvConfig names the environment variable that overrides which file the settings are read from. It exists so a test, or a person trying something out, can point at a scratch file without touching the real one.

View Source
const FanReach = 4

FanReach is how many panels either side of the middle one a frame considers.

Four, which is more than a rectilinear projection can ever show: at the far end of the distance range four screens span the view, so the fifth is off the edge whatever the splay. Considering one too many costs a projection that comes back refused; considering one too few loses a screen that should be in shot, and nothing would say so.

View Source
const FrameInterval = 16 * time.Millisecond

FrameInterval is how often the ribbon is advanced and redrawn.

It is not tied to the display's refresh rate. The captures are change-driven and mostly still, the warp costs 2.8 ms and the composite under one, so drawing more often would spend the budget re-deriving a picture nobody changed.

View Source
const MaxDistance = 4.0

MaxDistance is the far end: four screens across the view.

A float, because it is compared with and assigned to a distance and an untyped integer constant next to a float64 is a conversion waiting to be forgotten.

Chosen, like MaxScreens, and for the same kind of reason rather than a geometric one. At four, a 1920-pixel screen is 480 pixels of a 1920-pixel view: text on it is a texture, not words. Somebody who wants the whole desk at once has the gallery, which draws every screen at a readable size instead of pretending nine of them fit in one view.

View Source
const MaxScreens = 9

MaxScreens is the most screens a desk carries: nine.

It is an ARBITRARY ceiling, chosen rather than derived, and that is worth saying plainly because nothing in this package supplies one. The screens are flat, so the band is as long as it needs to be; the plan would spread forty of them over the turn without complaint, and a test used to.

Nine is where three things happen to agree:

  • the gallery is DefaultColumns wide, so nine fills three rows of three exactly, with nothing ragged and every screen keeping its column as the desk grows;
  • a screen costs a display to create and a stream to capture, and that cost is linear — on macOS it is a CGVirtualDisplay each;
  • past nine, a person stops holding a map of where things are, which is the whole point of a fixed arrangement.

A number asked for above it is CLAMPED rather than refused when it comes from the geometry (Plan.WithScreens), and REFUSED with the ceiling named when it comes from a person's settings file — a clamp is right for a program composing a plan and wrong for a line somebody wrote and expects to mean something.

View Source
const MaxSlantDeg = 80

MaxSlantDeg is how far a panel may be turned before it is dropped from the frame.

Eighty degrees, not ninety. At ninety a panel is edge-on: it projects to a line one column wide, and drawing it is a column of stretched pixels that reads as a scratch on the picture. The last ten degrees are worth nothing and cost a division by a depth approaching zero.

View Source
const MaxSplayDeg = 60.0

MaxSplayDeg is the widest angle between neighbours.

Sixty. Past it a chain of nine screens wraps round past the viewer's own shoulders and the far ones come back into shot from behind, which is not a desk -- and a panel turned that far is mostly edge, so its pixels are a smear whatever else is true. Chosen, like every other end in this package, and said so rather than derived.

View Source
const MinDistance = 1.0

MinDistance is the near end: one screen filling the view exactly, at one source pixel per output pixel.

It is the largest a screen can be shown in a given pair of glasses, which is what ActionFit returns to. Closer than this means seeing part of a screen, so the scale stops here rather than continuing into a crop.

View Source
const PhotoDirEnv = "XRDESK_PHOTO_DIR"

PhotoDirEnv moves where photographs are written. It is checked like any other choice: see PhotoPath.

View Source
const PhotoWait = 5 * time.Second

PhotoWait is how long to wait for the first frame after a camera is opened.

A camera is not instant: the session starts, the sensor powers up, the light comes on, and the first frame arrives a moment later -- measured at a few hundred milliseconds on the machine this was written on, and a headset over USB is slower than a built-in one. Long enough not to give up on a slow camera, short enough that somebody who pressed the key knows it has failed.

View Source
const PhotoWarmUp = 500 * time.Millisecond

PhotoWarmUp is how long the camera runs before the picture is taken.

A sensor powers up with its exposure at nothing and ramps, so the first frames a camera delivers are darker than what it can see. Half a second is what a camera application usually discards, and it is what this waits.

⚠ NOT MEASURED HERE, and it is worth being exact about why. A photograph taken from the first frame did come back 1920x1080 with a mean luminance of ZERO -- and so did the raw capture probe run against the same camera a minute later, which is the CONTROL that says the room was dark and not that the first frame was. The effect this guards against is real in general and was not the cause of what was seen, so this is a precaution and not a fix.

It is a WAIT rather than a test of the pixels, deliberately: a photograph of something genuinely dark is a photograph, and refusing it would be worse than taking it.

View Source
const SelectionWidth = 8

SelectionWidth is how thick that border is, in logical pixels.

Thick. A one-pixel ring round a tile that subtends a hand's width in a headset is a rumour; this is the whole answer to "which one am I about to choose", so it is drawn like one.

View Source
const SettingsFontPx = 13

SettingsFontPx is the settings window's type size in LOGICAL pixels, before the metric scale. Thirteen is a comfortable interface size; on a 2160-row panel the scale takes it to thirty-nine.

View Source
const SettingsRoom = 0.9

SettingsRoom is the fraction of a display's usable area the settings window may occupy. A dialogue that reaches the very edges of the screen reads as a mistake, and on macOS the bottom edge is where the Dock lives.

View Source
const SplayStep = 5.0

SplayStep is how much one press changes the angle: five degrees, so the whole range is twelve presses and each one is visible.

View Source
const SysfsUSB = "/sys/bus/usb/devices"

SysfsUSB is where Linux publishes what it enumerated.

View Source
const TrayIconPx = 44

TrayIconPx is the icon's LONGER side, in pixels.

Twice the menu bar's own height, so it is still sharp on a display that draws two pixels per point. The other side follows the glyph: a system symbol is not square -- visionpro is 21 by 13 points -- and the bar scales what it is given by height, so a picture forced square would show the glyph stretched.

View Source
const TrayQueue = 4

TrayQueue is how many menu choices are held for the run loop.

It is small on purpose: the loop reads continuously while the desk is up, so the only time anything queues is while the settings window has the desk stopped -- and a person clicking the menu five times then means the fifth click, not five actions replayed when the desk comes back.

View Source
const TrayRowPx = 32

TrayRowPx is a menu ROW's glyph, in pixels.

go-widgets/tray draws a row's icon at 16 points, so 32 pixels is that at 2x and a Retina display gets one image pixel per device pixel with nothing resampled. It is NOT TrayIconPx: a menu-bar icon sits in a 22-point bar and a row's icon sits beside the row's text, and a symbol rasterised for one and drawn at the other has strokes of the wrong weight.

View Source
const TrayTitle = "\U0001F453"

TrayTitle is the desk in one glyph, for anywhere a picture will not do -- a log line, a window title, a person asking what to look for in their menu bar.

View Source
const TrayTooltip = "XR desk"

TrayTooltip is what the item says when somebody rests on it.

Variables

View Source
var (
	// ErrAwaitQuit says the person asked to quit while waiting.
	ErrAwaitQuit = errors.New("desk: asked to quit while waiting for a display")
	// ErrAwaitSettings says the person asked for the settings while waiting.
	// The desk has nothing to show yet, and the settings are exactly where
	// somebody goes when the glasses are not being found.
	ErrAwaitSettings = errors.New("desk: asked for the settings while waiting for a display")
)

What Await stopped for, when it did not stop because a display arrived.

View Source
var (
	// ErrNoSuchOffer means nothing in the inventory has that identifier.
	ErrNoSuchOffer = errors.New("desk: no such source")
	// ErrPosition means the ribbon has no such position.
	ErrPosition = errors.New("desk: no such ribbon position")
	// ErrAlreadyShown means the source is already on another position.
	ErrAlreadyShown = errors.New("desk: that source is already on the ribbon")
	// ErrNoApps means no application has a window to show.
	ErrNoApps = errors.New("desk: no application with a window")
)

Errors an inventory can refuse with.

View Source
var (
	// ErrScreens means the requested number of screens is not usable.
	ErrScreens = errors.New("desk: unusable number of screens")
	// ErrFOV means the field of view given is not one.
	ErrFOV = errors.New("desk: unusable field of view")
)

Errors a plan can refuse with.

View Source
var DefaultBackground = [4]byte{12, 14, 18, 255}

DefaultBackground is a near-black that is not black, so a gap between screens reads as part of the arrangement rather than as a dead panel.

DefaultLadder is what is added to a taken combination, in order.

Control before Shift, deliberately. ⌥⌘Space is the Finder's search window on a stock macOS, so the gallery always falls back — and ⌃⌥⌘Space is both freer on a typical machine and easier to hold than ⌥⇧⌘Space, which puts three modifiers under one hand.

View Source
var DotInk = SelectionInk

DotInk is the colour of the light when a desk is up.

A green, and the same green the gallery uses for the screen it has chosen: one program, one word for "this one is live".

View Source
var ErrConfig = errors.New("desk: unusable settings")

ErrConfig says a settings file could not be used.

View Source
var ErrCrashed = errors.New("desk: the frame loop crashed")

ErrCrashed wraps whatever a frame loop panicked with.

View Source
var ErrNoDimming = errors.New("desk: darkening a display is not available on this platform")

ErrNoDimming is what a platform with no backlight control answers.

View Source
var ErrNoPointer = errors.New("desk: no way to move the pointer on this platform")

ErrNoPointer means this platform has no way for a program to move the mouse.

Not a gap to be filled in silence: an X11 pointer is XWarpPointer, a Wayland one CANNOT be moved by a client at all, and a Windows one is SetCursorPos -- three different answers with three different rules about who may ask. On the platforms where the desk shows the screens a machine already has, rather than ones it made, the pointer is on them anyway.

View Source
var ErrNoScreens = errors.New("desk: no screens")

ErrNoScreens means a desk was asked for with nothing to show.

View Source
var ErrNoSuchApp = errors.New("desk: no such application")

ErrNoSuchApp says nothing on this machine answers to that name.

View Source
var ErrPointerLost = errors.New("desk: cannot read the pointer's position")

ErrPointerLost means the window server would not say where the pointer is.

It is not the same thing as ErrNoPointer off darwin, which says the pointer cannot be MOVED here: this one is a machine that can move it and would not say where it is.

View Source
var SelectionInk = toolkit.RGB(0x7B, 0xD8, 0x8F)

SelectionInk is the colour of the border round the chosen screen.

APPLE GREEN, asked for from the glasses: "instead of orange it would be gentler on the eye to have an apple green or a sky blue". Orange was chosen for being the one strong hue that is in almost no window chrome and in very little wallpaper — findable at a glance — and it is also the hue a headset pushes hardest at a person's eye across a whole session.

Green keeps what mattered and drops what did not. It is still nothing any window chrome uses, it is still unmistakable against six captured desktops, and it is not the theme's accent — which is a blue that has to sit politely beside the interface it belongs to, where this has to be FOUND.

Sky blue was the other suggestion and is one line away: RGB(0x7F, 0xC8, 0xF8). It is not the default only because it is a near neighbour of the accent, and two blues that mean different things is how a person stops trusting either.

View Source
var WaitingInk = toolkit.RGB(0xE0, 0x6C, 0x6C)

WaitingInk is the colour of the light when there is no desk.

RED, because the menu bar should answer the question without being opened, and "no light at all" is not an answer -- an icon with nothing on it reads as an icon, not as a state. Asked for: "on pourrait avoir un point rouge quand les lunettes ne sont pas en action".

Muted rather than a signal red: it is a resting state, not a fault, and a menu bar full of alarm colours teaches a person to stop looking.

Functions

func Await

func Await(ctx context.Context, opt AwaitOptions) (glasses.Display, error)

Await waits for a display to show the desk on, and returns the one it found.

It exists because the glasses are a cable, and a cable is plugged in when a person gets round to it. The desk used to print "no display matches" and exit, which meant the order of two actions mattered for no reason: plug in, then start. Now it starts, says what it is waiting for, and begins by itself.

It returns AT ONCE when the display is already there, and logs nothing in that case: the normal path stays quiet.

While waiting it answers the menu bar — ErrAwaitQuit and ErrAwaitSettings — because the shortcuts are claimed by the desk's window, which does not exist yet. Nothing is created and nothing on the machine is changed until a display is found.

func BadgeDuration

func BadgeDuration(seconds float64) time.Duration

BadgeDuration turns a configured number of seconds into a duration, for a caller reporting what it will do.

func Billboards

func Billboards() []glasses.USB

Billboards lists the USB Billboard devices on the bus -- the class a USB-C device presents when an alternate mode it supports could not be entered.

func BringPointer

func BringPointer([]uint64, int) error

BringPointer reports ErrNoPointer.

func ConfigPath

func ConfigPath() (string, error)

ConfigPath reports the file the settings are read from.

That is $XRDESK_CONFIG when it is set, and otherwise desk.hcl under the platform's own configuration directory, beside the glasses catalogue:

~/Library/Application Support/go-xrkit/desk.hcl   (macOS)
~/.config/go-xrkit/desk.hcl                       (Linux, or $XDG_CONFIG_HOME)
%AppData%\go-xrkit\desk.hcl                       (Windows)

It fails only when the platform cannot say where a person's configuration lives, which on a Unix means HOME is unset.

func DarkNotePath

func DarkNotePath() (string, error)

DarkNotePath reports the file the notes are kept in.

func DisplayAt

func DisplayAt(x, y, w, h float64) (uint64, bool)

DisplayAt returns the display whose rectangle is exactly this one.

A rectangle identifies a display where a size does not: two identical monitors are the same size and are not in the same place. It is how the desk turns the screen it was given -- which it knows by name and geometry -- into the display id everything else here speaks in.

func DisplayOf

func DisplayOf(o Offer) (uint64, bool)

DisplayOf returns the display an offer captures.

The identifier's shape — "display-N" — is the convention EVERY platform's Sources builds with, and it is derived from the display rather than handed out in order so that the same screen keeps the same identity across a restart. Reading it back is what lets a caller say "this position is showing one of the machine's own panels, and it is that one".

It answers false for anything else, a window among other things: there is no display behind a window's picture to speak of.

func DisplayOfferID

func DisplayOfferID(id uint64) string

DisplayOfferID is the Offer id for a display, for a caller that has just created one and wants it opened.

func EvidenceFor

func EvidenceFor(d glasses.Display, _ bool, us []glasses.USB) *glasses.USB

EvidenceFor returns the bus evidence that belongs to this display, and nil when nothing ties any of it to this display.

The bus says which headsets are ATTACHED. It does not say which display is which headset, and those are not the same claim. On the desk this was written at, a pair of XREAL One S enumerated over USB with no video link at all while a 7680x2160 monitor was the only display present: taking the model from the bus and the pixels from that monitor produced a plan for "XREAL 1S: 7 screens of 3840x2160", which is a headset's optics wrapped round somebody's desktop screen. A wrong field of view renders everything, in the wrong place, with no symptom — which is the whole reason the catalogue refuses to guess.

So evidence is applied only where the DISPLAY ties it to itself: it names a headset, and one on the bus is that model — or names a brand, and one on the bus is of that brand. Anything else gets nothing, including a display the person named by hand: with two headsets attached and nothing to tell them apart, the answer is no evidence rather than a coin toss, and with one it is still a guess at a name that would be printed as fact.

func FitScale

func FitScale(want float64, maxW, maxH int, size func(float64) (int, int)) float64

FitScale is the largest magnification, at or below want, whose window still fits in maxW x maxH pixels. size reports the window's pixel size at a given scale.

Legibility and fitting are two different questions and they disagree. What makes the type comfortable on a 2160-row panel is a scale of three; what fits on the 1200-row display macOS may well put the window on is nothing like that. A window magnified past its screen is worse than a small one: the buttons are outside the frame, so the settings cannot be saved at all, and nothing on screen says why.

The measurement is handed in rather than computed here because the size depends on the FONT installed at that scale -- a row is a glyph high plus padding -- and installing a face is the display half's business. That also makes this the testable half: a synthetic size function proves the search without a window, a display or a font.

The step down is proportional, not a fixed decrement: if the window came out twice too tall, the useful next candidate is half the scale, not one notch less. It always shrinks -- the factor is a size over a smaller limit -- and it is bounded anyway, because this runs while a person waits for a window.

func Mirrors

func Mirrors(on []Offer, ours []uint64, own uint64) []uint64

Mirrors picks, out of what the ribbon is showing, the machine's own panels that may be darkened.

Two things are left out, for two different reasons.

The displays this program MADE are not the machine's: there is no panel behind them and nothing to darken.

And any display the size of the one the desk itself is on is left alone. That is deliberately more than it needs to be: a ribbon position CAN be pointed at the glasses' own display — the sources list offers it like any other — and darkening that one would black out the very thing the viewer is looking at, with the key that undoes it now invisible. There is no reliable way here to turn the chosen screen's NAME into a display id, so the size is used, and the failure it can produce is the harmless one: a second panel of exactly the same size stays lit.

func OwnDisplay

func OwnDisplay(name string) (uint64, bool)

OwnDisplay is the display id of the screen the desk's window is on.

It is wiring: window.Screens says where a named screen IS, DisplayAt says which display that is, and both of those need a real window server in front of them. What is DONE with the answer -- never darkening that screen, and bringing a pointer that lands on it back onto the ribbon -- is decided in wrap.go and dim.go, where it can be tested.

func Peripherals

func Peripherals() []glasses.USB

Peripherals lists every headset on the USB bus.

Linux publishes what it enumerated under SysfsUSB, so this reads three files and opens no device node: no permission, and no chance of disturbing whatever holds the glasses.

func PhotoPath

func PhotoPath(at time.Time) (string, error)

PhotoPath is where a photograph taken now may be written.

⛔ NEVER INSIDE A GIT WORK TREE, and that is not a convention -- it is the mistake this fleet has already made: a live test wrote a capture of a whole desktop into a public repository's testdata/, one `git add -A` from publication. A photograph from a headset is a picture of whatever the person wearing it was looking at, which is a stronger version of the same thing.

A .gitignore entry would be the wrong fix: ignoring is a safety net, not a barrier -- `git add -f`, a fresh clone, or any tool that does not consult it publishes the file anyway. go-fsctl/outdir is the barrier, and it REFUSES rather than choosing somewhere else, including when a person points XRDESK_PHOTO_DIR at their own work tree.

The name carries the moment, to the second, so two photographs taken in one session do not become one file.

func PointerHome

func PointerHome() func()

PointerHome has nothing to remember here.

func PutBackWhatWasLeftDark

func PutBackWhatWasLeftDark() ([]string, error)

PutBackWhatWasLeftDark lights any panel a previous run darkened and never restored, and reports what it did.

It is called at start-up, before anything else touches a backlight. A panel that is ALREADY brighter than the note says is left alone: somebody has turned it up since, and taking that away would be this program overruling a person to tidy up after itself.

func RememberScreens

func RememberScreens(path string, n int) error

RememberScreens writes the screen count into a settings file, leaving everything else in it exactly as it was.

⛔ It does NOT go through Config.Save. That renders the whole file from the struct with an empty hclwrite document, so everything the struct does not carry — every comment, every blank line somebody put between two blocks, the order they chose — is gone. That is defensible behind a Save button, where a person asked for their settings to be written down. It is not defensible on the side of an action that was about adding a screen: nobody clicking a tile in the gallery has agreed to have their file rewritten.

So the file is parsed and EDITED. hclwrite exists for exactly this: it keeps the tokens it did not touch.

A file that is not there yet is created holding only this, which is honest — it says the one thing that was decided and claims nothing else.

func Run

func Run(ctx context.Context, plan Plan, d *Desk, opt RunOptions) error

Run shows the desk until the viewer quits.

Everything here is portable. The window, the toolkit and the warp all run on macOS, Linux, Windows and in a browser, so this loop is the same everywhere — only the screens and their pixels arrive by different roads.

func RunSettings

func RunSettings(opt SettingsOptions) error

RunSettings shows the settings window and returns when it is closed.

It is a window of widgets and not a drawn picture: every control here is a toolkit widget, so it is navigable by keyboard, readable by an accessibility tree, and themed like everything else on the machine.

func Safely

func Safely(logf func(string, ...any), fn func()) (err error)

Safely runs fn and turns a panic into an error.

It exists because of what this program HOLDS while it runs, and what a panic does to that. The desk darkens the Mac's own panel while the ribbon shows a copy of it, and it puts the backlight back from a deferred call in the caller. A panic in the frame loop is a panic in ANOTHER GOROUTINE: it kills the process without running that deferred call, and somebody is left looking at a black screen with no menu bar to quit from.

That is not a hypothetical. It happened, with the glasses on:

panic: slice bounds out of range [:6684] with capacity 6680

and the report that came back was "I unplugged the glasses because I had lost access and there was no icon in the tray for me to stop the application" -- the icon was there, on a panel that had been turned off.

So a crash in the frame loop stops the desk the ordinary way instead: the run returns an error, every deferred restore runs, and the person gets their screen back. The stack is kept, because a crash that is swallowed silently is worse than one that is loud.

func Send

func Send(b Bench, ids []uint64, places []Placement) ([]string, error)

Send puts an application's windows on a ribbon position.

The position is an index into ids, which is the desk's own screens in the order the plan made them — so position 1 in a settings file is screen 1 on the band, and a person never has to know what a CGDirectDisplayID is.

It returns what it moved and what it could not, rather than the first error: a desk of six applications where one is not running should place the other five and say which one it did not.

func SettingsScale

func SettingsScale(displayH int) float64

SettingsScale is how much to magnify the settings window on a display of this height.

It is a LEGIBILITY choice and not a HiDPI derivation, which is worth being clear about: the back-end allocates one framebuffer pixel per logical point, so a widget tree is already the right size in the platform's own terms on every display. Nothing here has to compensate for anything.

What it compensates for is a person sitting in front of a very large panel. On the 8K screen this was reported against, the platform's own answer is a backing factor of one, so interface type comes out at its nominal size on a panel four times the area of a laptop's. A modest magnification reads better there; a proportional one does not -- scaling by pixel height gave a factor of three, and a dialogue magnified three times is a poster.

So: three steps, and the largest is a third again -- reported as still a little too big at half again, on the panel it was measured on. FitScale shrinks whatever this asks for until the window fits the display, so the worst case of getting this wrong is a window that is smaller than it might have been.

func ShouldChoose

func ShouldChoose(cfg Config, screen string, attached []glasses.USB) bool

ShouldChoose reports whether to put the settings window in front of somebody before starting.

Only when nobody has decided and the machine cannot: several headsets on the bus, no model in the settings, and no display named on the command line. Naming one with -screen is the answer for a script, and writing one in the settings is the answer for a person who has already chosen — this is for the case where neither has happened and picking one would be a guess.

func TakePhoto

func TakePhoto(camera string, logf func(string, ...any)) (string, error)

TakePhoto opens a camera, waits for a picture, writes it and reports where.

⛔ IT OPENS AND CLOSES AROUND ONE PHOTOGRAPH. A camera held open is a camera left ON -- the light with it -- and a desk that kept one for the length of a session would be a headset that watches the room all afternoon so that a key press can be quick. Opening costs a second; that is the right price.

camera is a avfoundation.Camera.ID, or empty for the first the machine lists. On a headset with several, which is the case this is written for, the caller chooses.

func TrayIcon

func TrayIcon(px int, live bool) ([]byte, error)

TrayIcon renders the menu-bar icon, as PNG bytes, with the light lit for the state given: green while a desk is up, red while there is none.

The SYSTEM's own symbol where there is one, and the toolkit's glasses otherwise. It is not a matter of taste: measured at 44 pixels, the toolkit's outline inks 7% of the box and a system symbol about 62%, and the difference is whether a person finds it among twenty other icons.

THE GLYPH IS RECOLOURED HERE because the platform has stopped doing it. An image carrying a colour is not a template, so macOS draws its pixels as they are -- and a system symbol is pure black, which on a dark menu bar sits among neighbours the platform has painted white at 85%. So the recolouring the platform would have done is done with the colour the platform would have used, read from the system when the icon is built rather than remembered: labelColor follows the appearance, and an icon built once would be wrong after somebody switches to dark.

func WritePhoto

func WritePhoto(p Picture, at time.Time) (string, error)

WritePhoto writes one picture where PhotoPath says it may go, and reports the path so a person can be told where to look.

⛔ IT SAYS WHERE. A photograph a program took and did not name is a photograph nobody can find, and the whole point of writing it somewhere durable rather than a temporary directory is that somebody comes back to it later.

Types

type Action

type Action int

Action is something the viewer asked for.

const (
	// ActionNone is a key that means nothing here.
	ActionNone Action = iota
	// ActionNext and ActionPrev turn the ribbon by one screen, the short way
	// round.
	ActionNext
	ActionPrev
	// ActionFullscreen promotes the focused screen to fill the view, or puts it
	// back.
	ActionFullscreen
	// ActionGallery opens the gallery — every screen at once, as a grid — or
	// closes it again leaving the ribbon exactly as it was.
	ActionGallery
	// ActionGalleryOpen opens the gallery and ActionGalleryClose leaves it,
	// each doing nothing when the gallery is already in that state.
	//
	// They exist alongside the toggle because a system-wide shortcut is pressed
	// blind: the viewer cannot see whether the gallery is open before deciding,
	// and one key that means "open" from outside and "close" from inside is one
	// key that does the wrong thing whenever they have lost track. Two keys
	// always mean what they say.
	ActionGalleryOpen
	ActionGalleryClose
	// ActionChoose takes the selected screen and returns to the ribbon focused
	// on it. It means nothing outside the gallery.
	ActionChoose
	// ActionUp and ActionDown move the selection in the gallery. On the ribbon
	// they mean nothing: a band has no rows.
	ActionUp
	ActionDown
	// ActionCycle asks for the next source on the focused screen. What that
	// means is the application's business — this package knows the ribbon, not
	// what a platform has to offer — so it is reported through OnCycle rather
	// than acted on here.
	ActionCycle
	// ActionQuit ends the session.
	ActionQuit
	// ActionSettings asks for the settings window.
	//
	// It ENDS the ribbon, like ActionQuit, and says so through
	// [Desk.WantsSettings] -- because the two windows cannot be on screen at
	// once. A back-end holds one window: the ribbon covers a display entirely
	// and takes the keyboard, and a settings window opened behind it would be a
	// dialogue nobody can see waiting for an answer nobody can give.
	//
	// So the desk stops, the settings are changed, and the desk starts again on
	// them. Which is also the only order in which a changed setting can take
	// effect: the screen count, the headset and the shortcuts are all read on the
	// way in.
	ActionSettings
	// ActionCloser and ActionFurther move the band towards the viewer and away
	// from them, by [DistanceStep].
	//
	// Further away is what shows the screens EITHER SIDE of the one in front:
	// they do not move, they take up less of the view, which is what pulling a
	// monitor back does. Nearer stops at one screen filling the view exactly,
	// because closer than that means seeing part of a screen.
	ActionCloser
	ActionFurther
	// ActionFit puts the band back where ONE screen fills the view exactly:
	// the largest a screen can be shown in these glasses, at one source pixel
	// per output pixel.
	//
	// It is [MinDistance], not a computed best. The near end of the range is
	// already defined as "one screen filling the view exactly, because closer
	// than that means seeing part of a screen" -- so the best fit is not
	// something to search for, it is the end of the scale, and this is the key
	// that returns to it without pressing closer six times.
	//
	// It is not "all the screens at once". That is what the gallery is for, and
	// [MaxDistance] exists to refuse it: at four screens across the view a
	// 1920-pixel screen is 480 pixels of a 1920-pixel view, which is a texture
	// rather than words.
	ActionFit
	// ActionFlatter and ActionRounder change the angle between one screen and
	// the next, by [SplayStep].
	//
	// Flatter ends at one plane -- every screen square on, which is the band this
	// package drew before there was an angle and still what somebody wanting a
	// single wide surface should get. Rounder turns each screen further towards
	// the viewer, the way the two beside the middle one on a desk of three
	// monitors are turned.
	ActionFlatter
	ActionRounder
	// ActionPoint asks for the mouse pointer to be brought to the screen in
	// front of the viewer.
	//
	// It is the answer to the thing that made the desk unusable: the picture
	// shows screens that applications are running on, and the pointer is
	// somewhere else on the desktop -- reachable only by dragging it blind across
	// displays whose contents are captures of somewhere else. One key ends that.
	//
	// Moving a pointer is the platform's business, not this package's, so it is
	// reported through OnPoint rather than acted on here -- the same seam as
	// ActionCycle and for the same reason.
	ActionPoint
	// ActionApps opens the gallery of running APPLICATIONS, or closes it.
	//
	// It is the other gallery. The screen gallery answers "which desktop am I
	// looking at"; this one answers "what is open, and where is it" — a grid of
	// the applications that have a window, each saying which screen it is on.
	//
	// From it, ActionChoose puts the highlighted application on the screen the
	// band is showing. That pairing is deliberate: a system-wide shortcut is
	// pressed blind, and "put this where I am looking" needs no second key and
	// no number typed at a picture the person may not be able to see.
	ActionApps
	// ActionSpread hands out one screen per application, in order, up to the
	// ribbon's screen count.
	//
	// One key, and a desk of six empty desktops is a desk of six applications.
	// Applications past the last screen are LEFT WHERE THEY ARE rather than
	// wrapped onto a screen that already has one: two windows on one screen
	// hides one of them, and a person who pressed one key cannot be expected to
	// guess which.
	ActionSpread
	// ActionRemove takes the selected screen off the band, in the gallery.
	//
	// The gallery is where a person looks at the desk they have, so it is where
	// they will want one fewer as well as one more -- and the alternative is a
	// settings file, which is not where anybody is when they run out of use for
	// a screen. It means nothing outside the gallery and nothing on the cell
	// that ADDS one.
	ActionRemove
	// ActionAppsOpen shows the application gallery, and does nothing when it is
	// already up.
	//
	// It exists beside the toggle for the same reason ActionGalleryOpen does: a
	// system-wide shortcut is pressed BLIND. One key that means "show me what is
	// running" from outside and "put it away" from inside does the wrong thing
	// every time the person has lost track of which they are in.
	ActionAppsOpen
	// ActionStereo3D turns the 3D conversion on or off; ActionStereo3DOn and
	// ActionStereo3DOff say which, without asking what it is now.
	//
	// The same reasoning as the gallery: a system-wide shortcut is pressed
	// blind. Through the glasses the viewer can see whether the picture has
	// depth, so the toggle is honest there -- but a key that means "on" from a
	// laptop and "off" from the headset is a key that does the wrong thing
	// whenever they have lost track.
	//
	// It is not a navigation action, so it is answered before the ribbon, the
	// gallery and the application list are consulted: how the picture is SHOWN
	// has nothing to do with which screen is focused.
	ActionStereo3D
	ActionStereo3DOn
	ActionStereo3DOff

	// ActionScreen1 through ActionScreen9 go straight to that screen, the short
	// way round, instead of turning one at a time or opening the gallery to
	// pick.
	//
	// Nine, and not ten, because [MaxScreens] is nine: there is a digit key for
	// every screen a desk can carry and none spare. That is not a coincidence
	// worth hiding -- if the ceiling ever moves, this list is the second place
	// it has to move too, and a tenth screen with no key is a screen only the
	// gallery can reach.
	//
	// They are written out rather than derived from a base plus an offset. The
	// arithmetic would be shorter and would break the day somebody appends an
	// action in the middle of the run, silently, by turning "screen 4" into
	// something else.
	ActionScreen1
	ActionScreen2
	ActionScreen3
	ActionScreen4
	ActionScreen5
	ActionScreen6
	ActionScreen7
	ActionScreen8
	ActionScreen9

	// ActionPhoto takes a photograph through one of the glasses' cameras.
	//
	// ⛔ IT IS A DELIBERATE ACT AND IT SAYS SO. A camera on a headset points at
	// whatever the person wearing it is looking at, so this is never
	// automatic, never on a timer, and never a side effect of anything else --
	// and the desk puts the path it wrote on the picture, because a photograph
	// a program took and did not name is a photograph nobody can find.
	//
	// The light on the glasses is on for as long as the camera is open, which
	// is the hardware's doing and not something this could suppress if it
	// wanted to.
	ActionPhoto
)

The actions a desk understands.

func KeyAction

func KeyAction(code string) Action

KeyAction maps a key to what it does.

These are the keys that work while the desk itself has focus. The shortcuts the viewer actually reaches for — turning the ribbon while working INSIDE one of the screens — cannot be these, because the focus is then in somebody else's application. Those are system-wide shortcuts and they are registered separately; this table is what remains useful when the desk is in front.

func (Action) String

func (a Action) String() string

String renders an action for a log.

type App

type App struct {
	// Name is the application's own name, as the window server reports it.
	Name string
	// PID is the process it runs in, from the first window seen of it. Two
	// processes with the same application name are one row in a gallery and one
	// of these -- which is what a person means by "Firefox" whether or not it
	// is two copies.
	PID int32
	// Icon is the application's own icon, or nil when nobody looked it up or
	// the system would not give it. A gallery falls back to a drawn glyph.
	Icon *Icon
	// Windows is how many windows it has.
	Windows int
	// On is the ribbon positions its windows are on, ascending and without
	// repeats. Empty means every window of it is somewhere else — on a real
	// display, most likely the one the person is looking at.
	On []int
	// Minimized is how many of its windows are in the Dock. A minimized window
	// can still be moved, so it is counted rather than hidden, but a gallery
	// should say so: an application that is entirely minimized looks missing.
	Minimized int
}

func AppsFrom

func AppsFrom(list []accessibility.WindowInfo, ids []uint64) []App

AppsFrom groups a listing of windows into applications, and works out which ribbon position each window is on.

It is pure, which is the point: the listing comes from the platform once, and everything a person then chooses over is decided here, where it can be tested.

ids is the desk's screens in ribbon order — the same slice Send takes — so position 1 in a settings file, screen 1 on the band and "on screen 1" in a gallery are the same screen. A window on a display that is not the desk's counts towards the application's window total and towards no position.

The result is sorted by name, so a gallery does not reshuffle itself between two looks at it. Windows with no application name are ignored: there is nothing to show and nothing to move.

func (App) Here

func (a App) Here() bool

Here reports whether any window of this application is on the desk.

func (App) String

func (a App) String() string

String names an application the way a gallery cell would.

type AwaitOptions

type AwaitOptions struct {
	// Want is the display or headset asked for: a -screen name, or the model
	// from the settings. Empty means "whatever is here", which never waits,
	// because [glasses.ChooseDisplay] always finds something to use.
	Want string
	// List answers what is attached NOW. Required.
	List func() ([]glasses.Display, error)
	// Actions is the menu bar, so a person is not stuck with a program that
	// only waits. Nil is allowed and means nothing can interrupt but the
	// context.
	Actions <-chan Action
	// Every overrides [AwaitPoll].
	Every time.Duration
	// Asked says the person has ALREADY been asked which headset to use, and
	// did not choose one.
	//
	// Without it a settings file naming a headset that is not here, and a
	// headset here that nobody has chosen, is a loop: ask, no answer, ask
	// again. Measured by the bench on an unattended machine -- the settings
	// window opening over and over in one session's log -- and it would look
	// the same to a person who closed it.
	//
	// Asked once, the answer is to get on with it: use the headset that is
	// here and say so.
	Asked bool

	// Logf says what is happening. Nil is silence.
	Logf func(string, ...any)
}

AwaitOptions is what Await needs.

type Bench

type Bench interface {
	// Displays lists what the window server is driving, virtual ones included.
	Displays() ([]accessibility.Display, error)
	// Windows lists the windows of every application whose name contains app,
	// case-insensitively. A person types "safari", not "Safari".
	Windows(app string) ([]accessibility.Window, []string, error)
	// Trusted reports whether this process may move another one's windows.
	Trusted() bool
	// Listing is every window on the machine, each attributed to the display it
	// is mostly on. It holds no handles open, which is what makes it usable for
	// a gallery: the list is read, shown and chosen from, and only the choice
	// opens anything.
	//
	// Applications that AX declines to describe do not appear. That is the
	// window server's answer rather than a gap here — Activity Monitor answers
	// AXError -25204 to any question about its windows on the machine this was
	// written on.
	Listing() ([]accessibility.WindowInfo, error)
}

A Bench is what putting an application on a screen needs of the platform.

It is an interface because the whole of the policy below — which windows an application has, which screen a position is, what to do when the application is not running or the screen is not there — is worth testing on a machine with no windows on it at all, which is every machine a test runs on.

func TheBench

func TheBench() Bench

TheBench is the platform, for Send.

macOS is the only one wired up. Moving another application's window is the Accessibility API's job there; Linux has the same idea in each window manager's own protocol and Windows in SetWindowPos, and until those are written this answers that it cannot — which is a thing a person can be told, rather than a placement that silently does nothing.

type Canvas

type Canvas struct {
	Pix  []byte
	W, H int
	// contains filtered or unexported fields
}

Canvas is the picture the screens are drawn into, and the window shows.

It used to be a panorama that a projection then read from. There is no projection any more: the screens are flat, so the buffer they are composited into IS what the glasses are given.

func NewCanvas

func NewCanvas(w, h int) *Canvas

NewCanvas allocates a picture of the given size.

func (*Canvas) Blit

func (c *Canvas) Blit(b ribbon.Blit, src Source)

Blit draws one screen into the canvas, where the ribbon said it goes.

It does not gather pixel by pixel. The horizontal mapping is a linear stepper whose step, for a panorama sized to show a screen at its own resolution, is very near one — measured at 0.99982 for a VITURE Beast. A step that near one means the source column advances by exactly one for long stretches, breaking only when the accumulated fraction wraps: over a thousand columns at a time.

So the columns are decomposed into RUNS of consecutive source pixels, once per blit, and each run is copied as a block. Every row of the rectangle reuses the same decomposition, because on a cylinder a screen's longitudes do not vary with height.

Gathering one pixel at a time cost 8.7 ms a frame. It was not the arithmetic: it was doing four-byte copies two and a half million times.

func (*Canvas) Compose

func (c *Canvas) Compose(blits []ribbon.Blit, sources []Source, background [4]byte)

Compose fills the canvas and draws every blit, which is one frame's worth of panorama. sources is indexed by ribbon screen number; a screen with no source yet — a display just created, a capture not started — is simply left as background rather than drawn as garbage.

func (*Canvas) ComposeSlants

func (c *Canvas) ComposeSlants(slants []Slant, sources []Source, background [4]byte)

ComposeSlants is Canvas.Compose for turned screens: the background, then every panel in the frame.

Back to front is not a question here. A chain of panels hinged edge to edge cannot overlap in the view -- each one starts where the last one ended -- so the order they are drawn in cannot change the picture. That is a property of the arrangement, not of the loop, and it is why there is no depth sort in a renderer that draws a three-dimensional thing.

func (*Canvas) Fill

func (c *Canvas) Fill(px [4]byte)

Fill paints the whole canvas one colour, which is what the gaps between screens show.

func (*Canvas) Slant

func (c *Canvas) Slant(s Slant, src Source)

Slant draws one turned screen: a column at a time, each from its own source row range.

It is the slow path and it says so. Canvas.Blit copies whole runs of a row because every row of a rectangle reads the same columns; a trapezoid's columns each have their own height, so there is no run to copy and the pixels are gathered one at a time. That is the four-byte-copy cost the fast path exists to avoid, which is why the screen in FRONT of the viewer -- the one turned by nothing, and the only one whose pixels a person is reading -- goes through Blit and only its neighbours come here.

Rows outside, columns inside: the writes then run along the canvas in order, and it is the reads that jump. One of the two has to, and a sequential write is worth more than a sequential read on every machine this has been measured on.

type Closer

type Closer interface{ Close() error }

Closer is what OpenTray returns: the item, to be taken out of the menu bar when the session ends.

It is io.Closer by shape and not by import, so the portable half of this file carries no dependency on the platform half.

type Config

type Config struct {
	// Shortcuts are the system-wide combinations, by action name.
	Shortcuts []ConfigShortcut `hcl:"shortcut,block"`

	// Fallback is what to add, in order, when a combination is already taken.
	// Nil means [DefaultLadder]; an explicitly empty list means do not fall
	// back at all, and report the refusal instead.
	Fallback *[]string `hcl:"fallback"`

	Ribbon  *ConfigRibbon  `hcl:"ribbon,block"`
	Glasses *ConfigGlasses `hcl:"glasses,block"`

	// Places are the applications to put on the band at start-up.
	Places []ConfigPlace `hcl:"place,block"`
}

Config is everything a person may set without a Go toolchain.

HCL rather than JSON, for the two reasons a person gives when asked: it takes comments, so a file can say WHY a shortcut was moved, and it has a schema, so a typo is a diagnostic pointing at a line rather than a field silently left at its zero value.

func LoadConfig

func LoadConfig() (Config, error)

LoadConfig reads ConfigPath. A file that is not there is not an error: it means every default stands.

func LoadConfigFile

func LoadConfigFile(path string) (Config, error)

LoadConfigFile reads one settings file.

func (Config) BadgeSeconds

func (c Config) BadgeSeconds() float64

BadgeSeconds is how long the screen's number stays up after the band moves. A file that does not say asks for DefaultBadgeSeconds; zero turns it off.

func (Config) Bytes

func (c Config) Bytes() []byte

Bytes renders the settings as the file they came from.

Written with hclwrite rather than by printing strings, so that what comes out is HCL by construction: a quotation mark in an application's name, or a name with a newline in it, cannot produce a file that then fails to parse. The round trip — write, read back, compare — is the test.

What is NOT set is left out entirely rather than written as a zero. A file with no `screens` line means "as many as the default says", and a file with `screens = 0` means the same thing while looking like a decision; only one of those is honest about what the person chose.

func (Config) Distance

func (c Config) Distance() float64

Distance is how far the band sits from the viewer, or 1 when the settings do not say. See Plan.Distance.

func (Config) HotkeyOptions

func (c Config) HotkeyOptions() *hotkey.Options

HotkeyOptions is what ClaimGlobal should be given for this configuration.

⛔ OnThisKeyboard, ALWAYS. A key name in a settings file is the LEGEND a person reads off the key in front of them, and a hotkey.Key is a virtual key code -- a POSITION, named after the ANSI legend for that position. On a French Mac the position called Equal prints "-", and "=" is over on the position called Slash. So `shortcut "fit" { keys = "ctrl+alt+cmd+Equal" }` claimed the key printed "-": the shortcut was granted, it fired, and pressing the key printed "=" reached nothing at all. Every check said it was granted, because it was. "le raccourci du fit ne fonctionne pas."

A desk shortcut is always the legend and never the position: there is no shape-under-the-hand here, only keys a person is told about in a menu.

func (Config) Immersive

func (c Config) Immersive() bool

Immersive reports whether to cover the glasses display's own menu bar and Dock. A file that does not say asks for it.

func (Config) Ladder

func (c Config) Ladder() []hotkey.Modifier

Ladder returns the fallback ladder this configuration asks for.

A file with no `fallback` gets DefaultLadder. A file with `fallback = []` gets no fallback at all, which is a real thing to want: it says "give me the combination I asked for or tell me you could not".

func (Config) Mirror

func (c Config) Mirror() bool

Mirror says whether the first ribbon position shows this Mac's own screen. A file that does not say asks for it.

func (Config) Model

func (c Config) Model() string

Model is the headset this configuration prefers when several are attached, or "" when it does not care.

func (Config) Placements

func (c Config) Placements() []Placement

Placements are the applications to put on the band, in the order written.

func (Config) Save

func (c Config) Save() (string, error)

Save writes the settings to ConfigPath, creating the directory if it is not there.

It writes a new file and renames it over the old one. A settings file half written is a settings file that will not parse, and the next start-up would then refuse to run over a power cut in the middle of a save.

func (Config) SaveTo

func (c Config) SaveTo(path string) error

SaveTo writes the settings to one named file.

func (Config) Screens

func (c Config) Screens() int

Screens is how many screens to put on the ribbon, or 0 for as many as fit.

func (Config) ShortcutsOr

func (c Config) ShortcutsOr(base []Shortcut) []Shortcut

ShortcutsOr returns the configured shortcuts, falling back to base for any action the file does not mention.

A settings file that names one shortcut must not silently drop the other two: a person moving the gallery key is not asking to lose the arrows.

func (Config) SplayDeg

func (c Config) SplayDeg() float64

SplayDeg is the angle between one screen and the next, or DefaultSplayDeg when the settings do not say. See Plan.SplayDeg.

A zero in the file is honoured: it means the flat band, which is a real choice and not an absence. Nil is the absence, and that is what gets the default.

type ConfigGlasses

type ConfigGlasses struct {
	// Model names which headset to use when several are attached, as the
	// catalogue names it.
	Model *string `hcl:"model"`
}

ConfigGlasses is the `glasses { }` block.

type ConfigPlace

type ConfigPlace struct {
	App    string `hcl:"app,label"`
	Screen int    `hcl:"screen"`
}

ConfigPlace is one `place "Safari" { screen = 2 }` block.

type ConfigRibbon

type ConfigRibbon struct {
	// Screens is how many virtual screens to put on the band. Zero asks for
	// [DefaultScreens].
	Screens *int `hcl:"screens"`

	// Splay is the angle between one screen and the next, in degrees. Nil means
	// [DefaultSplayDeg]; zero is the flat band. See [Plan.SplayDeg].
	Splay *float64 `hcl:"splay"`

	// Distance is how far the band sits from the viewer, as a multiple of the
	// distance at which one screen fills the view. Nil, or anything below one,
	// means one. See [Plan.Distance].
	//
	// It is here as well as on the keyboard because it is a PREFERENCE, not a
	// gesture: somebody who works with three screens in view wants three screens
	// in view every session, and setting it twelve times a day with a shortcut is
	// not a setting, it is a chore.
	Distance *float64 `hcl:"distance"`

	// BadgeSeconds is how long the screen's number stays up after the band
	// moves. Nil means [DefaultBadgeSeconds]; zero turns it off.
	BadgeSeconds *float64 `hcl:"badge_seconds"`

	// Mirror reserves the FIRST ribbon position for this Mac's own screen, and
	// makes one virtual display fewer. Nil means true.
	//
	// Somebody wearing the glasses still has a Mac in front of them, with a menu
	// bar, a Dock and whatever was already open on it. Reaching it should not
	// mean taking the glasses off, and it should not mean knowing a key: it is
	// screen 1, where the band starts.
	Mirror *bool `hcl:"mirror"`

	// Immersive covers the glasses display's own menu bar and Dock. Nil means
	// true.
	//
	// macOS draws a menu bar on EVERY display when Spaces are separate, so the
	// glasses carry one of their own — and being drawn at a level above an
	// ordinary window, it sits on top of the picture. Covering it is the whole
	// point: the desktop being shown has a menu bar of its own already, and two
	// of them was the first thing anyone noticed wearing this.
	//
	// Turn it off if the glasses are the MAIN display, where that bar and the
	// Dock are the real ones rather than a copy on a screen nobody is using.
	Immersive *bool `hcl:"immersive"`
}

ConfigRibbon is the `ribbon { }` block.

type ConfigShortcut

type ConfigShortcut struct {
	Action string `hcl:"action,label"`
	Keys   string `hcl:"keys"`
}

ConfigShortcut is one `shortcut "next" { keys = "..." }` block.

type Desk

type Desk struct {

	// Background is what the gaps between screens show.
	Background [4]byte

	// OnAdd, when set, is what the gallery's "add a screen" cell calls. It must
	// return a feed for the new screen — which on macOS means creating another
	// virtual display and capturing it.
	//
	// It is a callback and not something this package does, for the same reason
	// the feeds are handed in rather than opened here: making a display is the
	// platform's business and this file has no operating system in it.
	OnAdd func() (Feed, error)

	// OnCycle, when set, is called with the FOCUSED position when the viewer
	// asks for the next source there. It is called without the desk's lock held,
	// so a handler may call SetFeed — which is the whole point of it.
	OnCycle func(pos int)

	// OnStereo3D, when set, is told whenever the 3D conversion is turned on or
	// off. Like OnCycle it is called OUTSIDE the lock: whoever answers it opens
	// a depth model and talks to a GPU, which must not happen while the ribbon
	// is held.
	OnStereo3D func(on bool)

	// OnPoint, when set, is called with the position whose screen the pointer
	// should be brought to. It is called without the desk's lock held.
	//
	// The desk knows which screen a person is looking at; only the application
	// knows what a screen IS to the window server. So this is the seam, like
	// OnCycle -- and it is the one that makes the desk usable rather than merely
	// visible: without it the pointer has to be dragged blind across displays
	// whose contents are captures of somewhere else.
	OnPoint func(pos int)

	// OnRemove, when set, is called after a screen has been taken off the band,
	// with the position it was at and the feed that was on it.
	//
	// The desk has already shrunk by then: what is left is the platform work --
	// closing the capture and giving the display back -- which this package does
	// not do, for the same reason it does not create one.
	OnRemove func(pos int, f Feed)

	// OnScreens, when set, is called after the number of screens on the band
	// has changed by a viewer's doing -- the gallery's "+" tile, or taking one
	// away -- with the count there is now.
	//
	// It is the seam for REMEMBERING that: this package does not know where
	// settings live, and a caller that does can write the number down so the
	// desk comes back the same size. See [RememberScreens], which edits the
	// file rather than rewriting it.
	//
	// It does not fire from [Desk.Grow] or [Desk.Shrink] called directly, the
	// same way [Desk.OnRemove] does not: a program that called those already
	// knows what it did. This is for the paths a PERSON drives.
	OnScreens func(count int)

	// OnApps, when set, answers what is running whenever the application
	// gallery is opened. It is asked EVERY time rather than once, because the
	// list is what a person opened the gallery to see: an application that
	// quit two minutes ago must not still be offered a screen.
	//
	// A callback for the same reason as the others: enumerating windows talks to
	// the window server, and there is no operating system in this file.
	OnApps func() ([]App, error)

	// OnPlace, when set, is handed the placements the viewer asked for — one
	// from the application gallery, or a screen each from [ActionSpread].
	//
	// It is called without the desk's lock held, and it deliberately hands over
	// [Placement]s rather than doing anything: the live path is then the same
	// one the settings file uses at start-up, with the same menu-bar allowance
	// and the same reporting, instead of a second placement path that would
	// drift from it.
	OnPlace func(places []Placement)

	// OnPhoto takes a photograph and reports where it was written.
	//
	// It is a SEAM because a camera is the platform's: this package decides
	// when a photograph is taken and what is said about it, and the application
	// decides which camera and how. Nil is a desk with no camera, and asking for
	// a photograph then says so rather than doing nothing.
	OnPhoto func() (string, error)
	// contains filtered or unexported fields
}

Desk is the running arrangement: a plan, the screens on their ribbon, the feeds filling them, and the panorama they are drawn into.

Everything here is portable. It knows nothing about how a screen was created or how its pixels arrive — only that a Feed will hand them over. That is what lets the same logic run over ScreenCaptureKit, X11 and Windows, and what lets it be tested against feeds that are not screens at all.

func New

func New(plan Plan, feeds []Feed) (*Desk, error)

New builds a desk from a plan and one feed per screen.

The feeds are taken in the plan's own order, so feeds[i] fills the screen the plan calls screen-(i+1). A nil feed is allowed and simply shows background: a display that exists but is not being captured yet is a normal state during start-up, not an error.

func (*Desk) Advance

func (d *Desk) Advance(dt float64)

func (*Desk) Badge

func (d *Desk) Badge(seconds float64, theme *toolkit.Theme)

Advance moves the ribbon towards where it is going, dt seconds later. Badge turns the arrival badge on for this many seconds, or off at zero. It must be set before the first Render.

func (*Desk) Canvas

func (d *Desk) Canvas() *Canvas

Canvas is the panorama, for the warp to read.

func (*Desk) Click

func (d *Desk) Click(x, y int) bool

Click chooses a screen from the gallery, at a point in the picture's own coordinates. It reports whether the click landed on a screen.

One click goes there, rather than one to highlight and another to confirm. The gallery exists to be left: somebody who has found the screen they want has already decided, and asking them to say so twice is asking them to aim twice at a tile in a headset.

On the band it does nothing. A click on a captured desktop is not this application's to interpret — the desktop under the cursor will have its own idea of what was clicked.

func (*Desk) Close

func (d *Desk) Close() error

Close shuts every feed down, returning the first error but closing them all.

func (*Desk) Distance

func (d *Desk) Distance() float64

Distance is how far the band is from the viewer. See Plan.Distance.

func (*Desk) Do

func (d *Desk) Do(a Action)

Do carries out an action.

func (*Desk) Err

func (d *Desk) Err() error

Err reports what the last action returned, or nil. The gallery can refuse — a direction it has no cell for — and a refusal nobody can see is a key that silently does nothing.

func (*Desk) FeedAt

func (d *Desk) FeedAt(i int) Feed

FeedAt reports what is at a ribbon position, or nil for an empty one.

func (*Desk) Focus

func (d *Desk) Focus() int

Focus is the ribbon position in front of the viewer.

It is what a caller needs to put something THERE -- the screen a wandering pointer went to, most often -- without having to track every action that could have turned the band since it last looked.

func (*Desk) Grow

func (d *Desk) Grow(f Feed) (int, error)

Grow puts another screen on the band, at the end, and returns its position.

Everything that was sized for the old count is rebuilt: the placement, the band, the gallery. Rebuilt rather than grown, because the layout is DERIVED from the count — the pitch between screens is a share of the whole turn — so there is no version of this that adjusts one number and leaves the rest standing.

The viewer keeps the screen they were facing. Adding a seventh screen must not move the band, or a person who added one to put something on it would find themselves somewhere else.

func (*Desk) InGallery

func (d *Desk) InGallery() bool

InGallery reports whether a gallery covers the view -- either of them.

It is what tells a caller when the BARE keys are worth claiming: while a gallery is up the person is looking at the desk rather than working in an application, and the arrows should mean what they look like they mean. See GalleryShortcuts.

func (*Desk) Look

func (d *Desk) Look(pos int) error

Look turns the band to a screen without a key being pressed.

It is what lets the ribbon FOLLOW something — the pointer, today. A position it has no screen for is refused; the position it is already on is nothing, deliberately, because "follow" is asked several times a second and a band that re-aimed at the screen it is already on would never settle.

It is not a shortcut for Desk.Do: an action is what a person asked for, and this is the desk keeping up with them.

func (*Desk) Nav

func (d *Desk) Nav() *ribbon.Nav

Nav exposes the navigator, for a caller that wants to ask where the ribbon is.

func (*Desk) Plan

func (d *Desk) Plan() Plan

Plan returns what this desk was built from.

func (*Desk) Quit

func (d *Desk) Quit() bool

Quit reports whether the viewer has asked to stop.

func (*Desk) Render

func (d *Desk) Render() *Canvas

Render draws the current frame into the panorama and returns it.

Every feed is asked for its latest pixels whether or not it is visible: a feed that is never read may stop delivering, and the cost is a pointer.

func (*Desk) SetFeed

func (d *Desk) SetFeed(i int, f Feed) (Feed, error)

SetFeed replaces what one ribbon position shows, while the desk is running.

It returns the feed that was there, and does NOT close it. That is deliberate: a viewer who parks a screen to look at something else usually wants it back, and a method that closed it would make "swap" and "discard" the same gesture. A caller that really is finished with the old feed closes it itself.

The last picture from the old feed is dropped with it, so a position whose new feed has not produced anything yet shows background rather than the previous screen's contents — which would be a lie about what is on that panel.

func (*Desk) Shrink

func (d *Desk) Shrink(pos int) (Feed, error)

Shrink takes screen pos off the band and returns the feed that was on it.

The feed is RETURNED rather than closed, exactly as Desk.SetFeed does: what a caller wants to do with a capture it is no longer showing is the caller's business, and a method that closed it would make "put it away" and "throw it away" the same gesture.

It refuses to take the last screen. A desk of nothing is not a desk, and everything here — the ribbon, the gallery, the navigator — is built from a plan of at least one; the person who wants none wants to quit.

func (*Desk) WantsSettings

func (d *Desk) WantsSettings() bool

WantsSettings reports whether the desk stopped in order to show the settings rather than to end the session.

It is a separate question from Desk.Quit because both are true at once: the ribbon has to come down either way, and only the caller knows what to put in its place.

type Dimmer

type Dimmer struct {
	// contains filtered or unexported fields
}

Dimmer keeps the machine's own panels dark while a copy of them is on the ribbon.

It exists because of what mirroring MEANS. A person wearing display glasses that show their Mac's screen is looking at a copy; the panel itself is then a second, brighter copy of private work at reading distance, facing whoever walks past, lit at full power for nobody.

Turning the backlight off is not the same as covering the screen with a black window. The framebuffer is untouched, so the picture ON THE RIBBON does not change — there is no window for the capture to exclude, no stream to rebuild, and nothing another program can raise itself above.

The way home is read before anything is changed and kept here, so [Restore] can always be deferred. A program that darkens somebody's screen and then dies leaves them with a black panel and no idea why.

func (*Dimmer) Dark

func (m *Dimmer) Dark() int

Dark returns how many panels this dimmer is currently holding dark.

func (*Dimmer) Note

func (m *Dimmer) Note() error

note records what is dark, or removes the note when nothing is. Note writes down which panels are dark and what they were lit at, or removes the note when none are.

Called after every change, because the point of it is to be there when this process is not.

func (*Dimmer) Restore

func (m *Dimmer) Restore() error

Restore puts every panel back the way it was found. It is safe to call twice, which is what lets it be deferred beside a Showing that runs many times.

func (*Dimmer) Showing

func (m *Dimmer) Showing(ids []uint64) error

Showing tells the dimmer which of the machine's own displays are on the ribbon now. Panels that have just arrived are darkened; panels that have left are put back.

It is the whole API on purpose: the caller says what the world looks like and the dimmer works out the difference, rather than the caller having to remember what it darkened and pair every change with its undo.

type Fan

type Fan struct {
	// contains filtered or unexported fields
}

A Fan is the band with its screens TURNED: a chain of flat panels hinged edge to edge, each angled by the splay from the last, projected for wherever the viewer is looking.

It is the second half of being able to push the band back. Strip draws the band with every screen square on, which is what it looks like from directly in front and wrong for the ones off to the side: at a desk of three monitors the two beside the middle one are turned towards you. A Fan turns them.

The chain is INFINITE and the screen index wraps, which is how the band stays a ring: panel n is screen 0 again, one turn further along the chain. So walking right past the last screen arrives at the first, exactly as it did flat, and nothing about the navigator or the gallery had to learn what an angle is.

The screens are still FLAT. A curve bows the screen you are reading, which argues with the depth the glasses already present -- that was measured, worn, and deleted. A rotation leaves every panel flat and only changes which way it faces. See Slant for what one turned panel projects to, and why that is a trapezoid rather than a guess.

func NewFan

func NewFan(plan Plan) (*Fan, error)

NewFan prepares the chain for this plan.

It refuses a plan with no screens or no size, and a splay of nothing -- which is not a fan at all but the flat band, and Strip draws that better: every panel square on means every panel is a rectangle, and a rectangle is a run of row copies rather than a pixel at a time.

func (*Fan) Angle

func (f *Fan) Angle(j int) float64

Angle is where panel j of the chain is, in radians from straight ahead.

It is the angle to the panel's CENTRE, which is what the scroll interpolates between: the panels of a chain do not subtend equal angles -- the ones further along are further away -- so a position half way between two screens is half way between their two angles and not half of a fixed pitch.

func (*Fan) Frame

func (f *Fan) Frame(dst []Slant, focus int, toward float64) []Slant

Frame appends the panels in shot to dst: the focused screen and its neighbours, for a band that has moved `toward` screens past the focused one (see Strip.Toward).

The columns of the returned slants live in the Fan's own buffers, so every slant of one frame may be held at once (the drawing loop does) and the frame after this one overwrites them.

func (*Fan) SetSourceWidths

func (f *Fan) SetSourceWidths(w []int)

SetSourceWidths gives screens their own source widths.

It is not decoration: the columns of a turned panel are gathered one pixel at a time out of the source row, so a panel told it is 1920 pixels wide when its capture is 1670 reads past the end of the row. That is a PANIC, and it is how this was found -- "slice bounds out of range [:6684] with capacity 6680", where 6680 is 1670 pixels of BGRA, with the app running and the glasses on.

A nil or short slice, or an entry of zero, leaves that screen on the width every other screen has.

type Feed

type Feed interface {
	// Frame returns the latest pixels, and whether they are newer than the last
	// call. A feed with nothing yet returns a zero Source and false.
	Frame() (Source, bool)
	// Close releases the capture. It must be safe to call twice.
	Close() error
}

Feed is one screen's pixels, arriving over time.

It is an interface rather than a concrete capture handle because what is behind it differs completely per platform — ScreenCaptureKit, X11 shared memory, a Windows duplication texture — while what this package needs of it is the same everywhere: the most recent pixels, and whether they are new.

Frame must NOT allocate and must NOT copy: it hands back a borrowed view of whatever the capture already has. The whole frame budget is 16.6 ms and a capture that copies a 4K screen spends a fifth of it doing nothing useful.

func Capture

func Capture(ctx context.Context, plan Plan, s *Screens, logf func(string, ...any)) ([]Feed, error)

Capture opens a stream on every screen, in ribbon order.

A screen whose capture cannot be opened becomes a NIL feed rather than an error: one display refusing is not a reason to show the viewer nothing.

func OpenOffer

func OpenOffer(ctx context.Context, plan Plan, o Offer) (Feed, error)

OpenOffer starts capturing one source, ready to be put on a position.

type Fence

type Fence struct {
	// contains filtered or unexported fields
}

Fence keeps the pointer on the screen the band is showing.

THE MOUSE DOES NOT CHANGE SCREENS. The keyboard does.

It was the other way round and it did not work. The band followed the pointer, the pointer came back at the other end of the band when it was pushed off it, and a display nothing was showing was fetched onto the screen in front of the viewer when the pointer wandered onto it. Three mechanisms, each answering a hole left by the one before, and the report after every one of them was the same: "I have lost the mouse again".

The reason is simple enough once it is said. A person wearing display glasses sees ONE screen. The desktop under the pointer is several, most of them invisible, and a pointer that can leave the visible one is a pointer that can be somewhere its owner cannot look. No amount of following fixes that; it only decides how far away the mouse gets before something notices.

So the pointer stays. It is put back on the screen the band is showing, every frame, and the way to another screen is the key that turns the band -- which also brings the pointer, because the screen it is held to has changed.

func (*Fence) Step

func (f *Fence) Step(showing []uint64, focus int) (bool, error)

Step puts the pointer back on the screen at position focus, if it has left it. It reports whether it moved it.

showing is the display each ribbon position is showing, in order. A position showing nothing, or a display this machine will not measure, is not a fence: the pointer is left alone rather than held against a rectangle nobody knows.

type Gestures

type Gestures struct {
	// contains filtered or unexported fields
}

Gestures turns three-finger swipes on the trackpad into ribbon actions.

It exists because the keyboard is the only way to turn the ribbon, and a person wearing the glasses has a trackpad under their hand. The mouse is deliberately NOT that way in: the pointer used to change screens and it kept getting lost, which is why it no longer does. A GESTURE is not the pointer -- it moves nothing on screen and cannot wander onto a display nobody is looking at.

It asks for no permission. macOS delivers a three-finger swipe as a private event the Dock consumes first, and only when the person has bound the gesture to switching spaces; reading the contacts underneath depends on neither. See go-macos/multitouch.

func ClaimGestures

func ClaimGestures() *Gestures

ClaimGestures starts watching the trackpad. It never fails: a machine with no trackpad, or one where the framework will not load, yields a Gestures whose channel simply stays empty -- the desk is driven by the keyboard, and the gesture is an addition to it.

func (*Gestures) C

func (g *Gestures) C() <-chan Action

C is the actions the trackpad produces.

func (*Gestures) Close

func (g *Gestures) Close() error

Close stops watching. It is safe to call twice.

func (*Gestures) Why

func (g *Gestures) Why() error

Why says what stopped the trackpad being read, or nil when it is being read.

type Grid

type Grid struct {
	// contains filtered or unexported fields
}

A Grid is every screen at once, in front of the viewer.

It is the flat counterpart of the ribbon's gallery, and it is head-locked on purpose: the band may be anywhere, but the grid is always straight ahead, so opening it and closing it costs no motion and shows the ribbon exactly as it was left.

Left and right WRAP, because the band is a circle and the last screen really is next to the first. Up and down CLAMP, because the fold into rows is this type's invention and has no seam to walk through.

func NewGrid

func NewGrid(n, srcW, srcH, viewW, viewH, gap int) (*Grid, error)

NewGrid folds n screens of srcW x srcH into a view of viewW x viewH, in DefaultColumns where that holds them and in whatever leaves them biggest otherwise.

It lays out ONE MORE cell than there are screens: the last one is where a screen is added. A gallery is where somebody looks at the desk they have, so it is where they will want another one — and the alternative is a settings file, which is not where anybody is when they run out of room.

func NewGridCols

func NewGridCols(n, srcW, srcH, viewW, viewH, gap, cols int) (*Grid, error)

NewGridCols folds the screens into exactly cols columns. A cols of zero or less asks for the shape that leaves the screens biggest, and so does a cols that turns out to leave no cell at all: a person asking for six columns in a window too narrow for them should get a gallery, not a refusal.

func (*Grid) Adder

func (g *Grid) Adder() (int, bool)

Adder is the index of the cell that adds a screen, and false when this grid has none.

func (*Grid) At

func (g *Grid) At(x, y int) (int, bool)

At is the screen whose cell contains the point, in view coordinates.

It is how a mouse chooses a screen. The gaps between cells and the margin round the grid belong to nothing: a click there is a click on the background, which is a click that should do nothing rather than the nearest thing.

func (*Grid) Cell

func (g *Grid) Cell(i int) (x, y, w, h int, ok bool)

Cell is where screen i sits in the view, for a caller drawing a highlight round the selected one without asking this type for pixels.

func (*Grid) Cells

func (g *Grid) Cells() int

Cells is how many cells it draws, screens plus the one that adds one.

func (*Grid) Frame

func (g *Grid) Frame(dst []ribbon.Blit) []ribbon.Blit

Frame appends the blits for the whole grid and returns the extended slice. There is no offset argument, and that is the point: the grid is head-locked.

func (*Grid) IsAdder

func (g *Grid) IsAdder(i int) bool

IsAdder reports whether i is the cell that adds a screen.

func (*Grid) Len

func (g *Grid) Len() int

Len is how many screens the grid holds. It is what makes a Grid a ribbon.Selectable, so the navigator drives it without knowing that these screens are flat. Len is how many SCREENS this gallery is of, which is what a navigator needs of it — not how many cells it draws. The cell that adds a screen is this type's own furniture and is none of the navigator's business.

func (*Grid) Move

func (g *Grid) Move(d ribbon.Direction) error

Move walks the selection. Left and right wrap; up and down stay in their column, and do nothing when it has no cell that way.

func (*Grid) Screens

func (g *Grid) Screens() int

Screens is how many of the cells are screens.

func (*Grid) Select

func (g *Grid) Select(i int) error

Select highlights screen i.

func (*Grid) Selected

func (g *Grid) Selected() int

Selected is the highlighted screen.

func (*Grid) SetSourceWidths

func (g *Grid) SetSourceWidths(w []int)

SetSourceWidths gives cells their own source widths, for screens that are not the shape of the band.

The cells stay the same size -- a gallery whose tiles were different widths would be a grid nobody could scan -- so a narrower screen is drawn NARROWER INSIDE its cell, centred, rather than stretched across it. Stretching would be worse than the empty band it replaces: proportions a person recognises are how they tell one desktop from another at a glance.

A nil or short slice, or an entry of zero, leaves that cell as it was.

func (*Grid) Shape

func (g *Grid) Shape() (cols, rows int)

Shape is the grid's columns and rows, for a caller that wants to describe it.

type Hotkeys

type Hotkeys struct {
	// contains filtered or unexported fields
}

Hotkeys is a set of claimed global shortcuts, feeding one channel of actions.

func ClaimGallery

func ClaimGallery() *Hotkeys

ClaimGallery claims GalleryShortcuts with no fallback ladder.

No ladder on purpose: a bare arrow that could not be claimed must stay unclaimed rather than becoming ⇧← , which is a selection in every text field on the machine.

func ClaimGlobal

func ClaimGlobal(shortcuts []Shortcut, opts *hotkey.Options) *Hotkeys

ClaimGlobal claims each shortcut, substituting when it has to.

It returns a usable set even when nothing could be claimed: a platform with no global shortcuts, or a desktop where every candidate is spoken for, is a reason to run without them, not a reason not to run. What went unclaimed is in Hotkeys.Describe, for the application to show.

func (*Hotkeys) C

func (h *Hotkeys) C() <-chan Action

C is the actions the claimed shortcuts produce. It closes when every claim has been released.

func (*Hotkeys) Close

func (h *Hotkeys) Close() error

Close releases every claim and closes C.

func (*Hotkeys) Describe

func (h *Hotkeys) Describe() string

Describe says what was claimed and what was not, in the form a person needs to see rather than the form a log wants.

The claimed combination is SHOWN, not merely recorded. One of the three kinds of conflict — an application's own menu key — cannot be detected by anything, so a shortcut that registers without complaint may still be one another application was quietly using. Naming what was taken is what lets the viewer notice.

func (*Hotkeys) DescribeNames

func (h *Hotkeys) DescribeNames() string

DescribeNames is the same, spelled out: "Option-Command-Left" rather than "⌥⌘←".

The glyphs are what macOS prints on a menu and what a terminal shows perfectly. They are NOT in every font — rendered in a window with the toolkit's own, ⌥ ⌘ ⇧ ⌃ ← → all came out as nothing at all, so a line meant to say which combination was granted said "previous:" and stopped. Anywhere the font is not known, this is the one to use.

func (*Hotkeys) Granted

func (h *Hotkeys) Granted() map[Action]hotkey.Combo

Granted is the combination each action ended up with, as a menu prints it.

What was CLAIMED, not what was asked for. The ladder substitutes when a combination is taken, so the two differ exactly when it matters most -- a menu that printed the wanted one would send a person to press a key that does nothing, and the substitution is the one case where nobody can guess.

An action nothing was granted for is absent rather than empty, so a caller can tell "no combination" from "a combination that renders as nothing".

The COMBINATION and not a rendering of it: a menu row draws its own, and on macOS it draws it right-aligned in a column with every other row's, in the system's own glyphs. Handing over a string would have decided that for it.

type Icon

type Icon struct {
	Pix  []byte
	W, H int
}

An App is a running application with at least one window, and where its windows are on this desk.

It is a description, like Offer: it holds no handle on anything, so a gallery of applications can be built, shown and chosen from without opening a window of any kind — and tested on a machine with none. An Icon is an application's own icon, as straight RGBA, W*H*4 bytes.

It is pixels rather than a path or a handle because this package has no operating system in it: whoever fills it in knows how to ask -- on macOS that is go-macos/appicon, which needs no permission -- and everything here just draws it.

type Inventory

type Inventory struct {
	// contains filtered or unexported fields
}

Inventory is what CAN be shown, and what currently is.

It holds no pixels and opens nothing. It exists so that choosing what a screen shows is a decision a person makes over a list they can read, separately from the machinery that then makes it appear — and so that the choosing can be tested without a display anywhere in sight.

A source may be on at most ONE position at a time. Two positions showing the same display would each cost a capture of the same pixels, and a viewer turning the ribbon would pass the same screen twice and wonder which is real.

func NewInventory

func NewInventory(positions int, offers []Offer) (*Inventory, error)

NewInventory describes a ribbon of the given length and what may go on it.

Offers with a duplicate or empty ID are refused rather than silently collapsed: an identifier that names two things cannot be used to choose between them.

func (*Inventory) Assign

func (inv *Inventory) Assign(pos int, id string) error

Assign puts a source on a position, replacing whatever was there.

It refuses a source that is already elsewhere on the ribbon rather than moving it, because a viewer who meant to move it and a viewer who forgot where it was make the same gesture, and only one of them wants the other position emptied. Clear the other position first to say which was meant.

func (*Inventory) At

func (inv *Inventory) At(pos int) (Offer, bool)

At reports what is on a position, and whether anything is.

func (*Inventory) Clear

func (inv *Inventory) Clear(pos int) error

Clear empties a position. Clearing an empty one is not an error: a viewer pressing the same key twice meant it both times.

func (*Inventory) Cycle

func (inv *Inventory) Cycle(pos int) (Offer, bool)

Cycle moves a position to the next source that is not already shown, and to empty after the last one.

It is the whole of "choose what this screen shows" reduced to one key. A picker that lists everything is better once there is somewhere to draw it; until then this makes the choice reachable with the keyboard alone, and it stays useful afterwards for the viewer who just wants the next thing.

The second return is false when the position ended up empty.

func (*Inventory) Describe

func (inv *Inventory) Describe() string

Describe renders the whole arrangement for a log or a person.

func (*Inventory) Offers

func (inv *Inventory) Offers() []Offer

Offers is everything that could be shown, in the order it was given.

func (*Inventory) Positions

func (inv *Inventory) Positions() int

Positions is how many places the ribbon has.

func (*Inventory) Unused

func (inv *Inventory) Unused() []Offer

Unused is everything not currently on the ribbon, in the order offered.

func (*Inventory) Where

func (inv *Inventory) Where(id string) int

Where reports which position is showing a source, or -1.

type Kind

type Kind int

Kind is what sort of thing a source is.

const (
	// KindDisplay is a display whose pixels are captured — a real monitor, or
	// one this program created.
	KindDisplay Kind = iota
	// KindPanel is a surface this program renders onto and reads back. On
	// Android that is a virtual display carrying a WebView or a decoder: things
	// the platform can draw and a CGO-free Go process cannot.
	KindPanel
)

The kinds of source a ribbon position can hold.

func (Kind) String

func (k Kind) String() string

String names a kind for a person.

type Offer

type Offer struct {
	// ID identifies this source within an inventory, and is stable enough to
	// survive being written down and used later.
	ID string
	// Name is what a person should be shown.
	Name string
	// Kind is what sort of thing it is.
	Kind Kind
	// W and H are its pixel size, or zero when it has none yet — a panel's size
	// is decided when it is opened.
	W, H int
	// Main reports the machine's own primary screen: the one carrying the menu
	// bar, which is what somebody means by "my Mac's screen".
	//
	// A field rather than a word in the name. The name is for a person to read
	// and a caller that looked for "(main)" in it would be reading a sentence
	// back out of a label -- and would stop working the day the label changed,
	// silently.
	Main bool
}

Offer is something that can be put on a ribbon position.

It is a description, not a handle: an inventory can be shown to a person, and a list of open capture streams cannot. Turning one into pixels is the platform's job, and happens only when a position actually takes it.

func Sources

func Sources(ctx context.Context, _ *Screens) ([]Offer, error)

Sources lists everything on this machine that a ribbon position could show.

func Without

func Without(offers []Offer, displays ...uint64) []Offer

Without returns the offers with the ones showing these displays left out.

It is how the desk keeps its OWN screen out of its own band. The glasses are a display like any other to the window server, so the sources list offers them -- and a position showing them shows this program's window, which is showing that position: a picture inside itself. Measured, with a session whose virtual displays were refused and which fell back to the two screens the machine had: "screen 2: display 3", display 3 being the glasses.

A display that is not offered is not merely hidden from the cycle: it cannot be assigned, so nothing can put it back by accident.

func (Offer) String

func (o Offer) String() string

String renders an offer the way a person would identify it.

type Options

type Options struct {
	// Screens is how many virtual screens to put on the ribbon. Zero asks for
	// as many as fit round the circle without overlapping.
	Screens int

	// Distance is how far the band sits from the viewer, as a multiple of the
	// distance at which one screen fills the view. Zero, like one, is the near
	// end. See [Plan.Distance].
	Distance float64

	// SplayDeg is the angle between one screen and the next, in degrees. A
	// NEGATIVE value asks for the flat band; zero asks for [DefaultSplayDeg],
	// because zero is what a caller that has not thought about it passes.
	SplayDeg float64

	// FOVDeg overrides the catalogue's horizontal field of view, in degrees.
	// It exists because the catalogue is honest about what it does not know, and
	// because a person who measures their own optics should be able to say so.
	FOVDeg float64

	// USB is what the headset says about itself over the bus, when the caller
	// has looked. It may be nil.
	//
	// It is STRONGER evidence than the display name and is consulted first. A
	// display name is whatever the panel puts in its EDID, and a dock, a
	// capture card or a KVM in the path can replace it with something generic;
	// a USB product id names a model, and for some brands it is the only thing
	// that does. On these very glasses the bus says "XREAL 1S" while the
	// display is not up at all.
	USB *glasses.USB
}

Options are the choices a person makes.

type Picture

type Picture struct {
	Pix    []byte
	W, H   int
	Stride int
}

Picture is one frame to be written, in the shape both a camera and a canvas already have.

BGRA, because that is what everything upstream of it carries: AVFoundation delivers BGRA and the desk's own canvas is painted through a BGRA painter. Converting on the way in would be a second copy of every frame to save one byte-swap on the few that are ever written.

type Placement

type Placement struct {
	App string
	Pos int
}

Placement is one line of a desk: an application, and the position on the ribbon its windows belong on.

func Spread

func Spread(apps []App, screens int) []Placement

Spread hands out one screen per application, in order, up to screens.

One press, and a desk that was showing six empty desktops is showing six applications. What it will NOT do is wrap: an application past the last screen is left exactly where it is, because moving two applications onto one screen hides one of them, and a person who pressed one key cannot be expected to guess which.

Applications already on a screen are placed too, and deliberately: the point of the key is that the desk ends up in a state a person can predict — the first application on screen 1, the second on screen 2 — not that it ends up in whatever state the previous arrangement plus this press produces.

The result is [Placement]s, so the live path is the one the settings file already uses. It never returns more placements than there are screens.

type Plan

type Plan struct {
	// Model is the headset this was worked out for.
	Model string

	// How says what named the model, so a caller can show whether the answer
	// came from the bus, from the display, or from the person at the keyboard.
	How glasses.How

	// ScreenW and ScreenH are the pixel size to create each virtual display at:
	// one eye's viewport, which is the most the glasses can show at once.
	ScreenW, ScreenH int

	// Stereoscopic reports whether the display is in a side-by-side 3D mode, in
	// which case the two eyes get different pixels of the same frame.
	Stereoscopic bool

	// HFOVDeg and VFOVDeg are one eye's field of view, in degrees.
	HFOVDeg, VFOVDeg float64

	// Layout places the screens. DensityDeg is derived so that a screen of the
	// eye's own shape spans exactly HFOVDeg.
	Layout ribbon.Layout
	// contains filtered or unexported fields
}

Plan is everything the renderer needs, worked out from the headset itself.

The rule that shapes all of it is the one the viewer asked for: ONE SCREEN IS ONE FULL VIEW. A virtual screen is created at exactly one eye's resolution, and given exactly the arc that eye can see, so that looking straight at a screen shows it edge to edge, at one source pixel per output pixel.

func NewPlan

func NewPlan(d glasses.Display, opts Options) (Plan, error)

NewPlan works out how to fill these glasses.

func (Plan) Count

func (p Plan) Count() int

Count is how many screens the ribbon carries.

func (Plan) Distance

func (p Plan) Distance() float64

Distance is how far the band sits from the viewer, as a multiple of the distance at which one screen fills the view exactly.

One is the near end and the doctrine this started from: a screen is the whole view, at one source pixel per panel pixel, and its neighbours are off to the sides where the head has to turn to reach them. Two puts each screen in half the width, so the two beside it are visible without turning. That is the whole of what "further away" means here -- a screen does not move, it takes up less room, which is what moving a monitor back does.

It is deliberately not allowed below one. Closer than filling the view means seeing PART of a screen, and a desk whose middle screen is cropped is not a desk anyone asked for.

func (Plan) ScreenWidth

func (p Plan) ScreenWidth(i int) int

ScreenWidth is how wide screen i is, which is Plan.ScreenW unless it has been given a shape of its own.

func (Plan) Screens

func (p Plan) Screens() []ribbon.Screen

Screens is the ribbon's screens: each one a whole view of the glasses, except any that has been given a shape of its own by Plan.WithScreenWidth.

func (Plan) SplayDeg

func (p Plan) SplayDeg() float64

SplayDeg is the angle between one screen and the next, in degrees.

Zero is the flat band: every screen in one plane, square on, which is what this package drew before there was an angle at all and still the right answer for somebody who wants a single wide surface. Anything more turns each screen towards the viewer, the way the two beside the middle one on a desk of three monitors are turned.

It is an angle between NEIGHBOURS and not a total arc, so it means the same thing whatever the screen count -- which is the point: three screens at twenty degrees and nine at twenty degrees have the same feel in front of you and differ in how far round the rest of them go.

The field cannot be negative -- Plan.WithSplay clamps at nothing and it is the only way in -- so there is no guard here to read past. A plan's zero value is the flat band, which is the right thing for it to be.

func (Plan) String

func (p Plan) String() string

String renders the plan the way a person would want it logged.

A field of view of zero is not printed as "0.00°": it means NOT KNOWN, and a number is a claim.

func (Plan) WithDistance

func (p Plan) WithDistance(d float64) Plan

WithDistance is this plan seen from a different distance, clamped to 1..MaxDistance.

Nothing about the screens changes: the band is the same ring of the same panels at the same resolution. What changes is the number of pixels each one occupies in the view, which is the pixel scale of the whole band -- so this is one multiplication in one place, and the navigator, the gallery and the captures know nothing about it.

func (Plan) WithScreenWidth

func (p Plan) WithScreenWidth(i, w int) Plan

WithScreenWidth returns the plan with screen i that many pixels wide, at the same height as every other screen. A width of zero or less puts it back to the shape of the glasses.

The plan is copied, widths and all: a plan handed out and then changed under its holder is a band and a navigator disagreeing about where a screen is.

func (Plan) WithScreens

func (p Plan) WithScreens(n int) Plan

WithScreens is this plan with a different number of screens on the band, clamped to 1..MaxScreens.

The screens are FLAT, so the angles are a scroll coordinate and nothing more, which is what lets one be added while the desk is running.

A curved band had to fit in 360 degrees, and at one screen per view that was seven of them and no more. Flat, the circle is a fiction: the yaw says how far along the band the viewer is, and the band is however long it needs to be. So n screens are spread over the full turn whatever n is -- and the ceiling is therefore a DECISION, not a consequence. See MaxScreens.

func (Plan) WithSplay

func (p Plan) WithSplay(deg float64) Plan

WithSplay is this plan with a different angle between neighbours, clamped to 0..MaxSplayDeg.

Like the distance, it changes nothing about the screens themselves -- same count, same resolution, same order. It changes which way each one faces.

type RunOptions

type RunOptions struct {
	// Title names the window. Nobody sees it in the glasses; a window manager
	// and an accessibility tree do.
	Title string
	// Screen is the display to take over.
	Screen glasses.Display
	// For stops the session after this long. Zero runs until the viewer quits,
	// which is what a person wants and what a test does not.
	For time.Duration
	// Logf receives progress. A nil Logf says nothing.
	Logf func(string, ...any)

	// Shortcuts are the system-wide combinations to claim. Nil asks for
	// [DefaultShortcuts].
	Shortcuts []Shortcut
	// Hotkeys is how to claim them — chiefly the fallback ladder. Nil asks for
	// [DefaultLadder].
	Hotkeys *hotkey.Options
	// OnGranted receives the combination each action ended up with, once they
	// have been claimed.
	//
	// It exists because a claim is not a grant. The menu-bar item is made before
	// any session and outlives every one of them, so it cannot know what the
	// window server will hand over -- and the ladder substitutes when a
	// combination is taken, which is exactly the case a person cannot guess. So
	// the answer is sent out when it is known rather than predicted beforehand.
	//
	// Nil is nobody asking, which is the default.
	OnGranted func(map[Action]hotkey.Combo)

	// Actions are actions from somewhere other than the keyboard: a menu-bar
	// item, a script, a remote. They are treated exactly like a global shortcut
	// -- the same actions, the same loop -- because the difference between
	// pressing a key and choosing a menu row is the caller's business and not
	// this loop's.
	//
	// Nil is no such source, which is the default.
	Actions <-chan Action

	// DepthModel names a Core ML depth model — an .mlpackage or a compiled
	// .mlmodelc — for the 3D conversion. Empty falls back to depth guessed
	// from the picture itself, which needs nothing and is visibly worse.
	//
	// It is opened only when a viewer asks for 3D, and closed when they stop:
	// the model costs a GPU context and a second of loading, and a desk that
	// never turns it on should pay neither.
	DepthModel string
	// Stereo3D starts with the conversion already on.
	Stereo3D bool

	// Badge is how long the screen's number stays up after the band moves, in
	// seconds. Zero turns it off.
	Badge float64

	// Windowed leaves the glasses display's own menu bar and Dock on top of the
	// picture instead of covering them. See [Config.Immersive].
	Windowed bool

	// Interactive lets the desk's own window take the keyboard and the mouse.
	//
	// It does NOT by default, and that is the whole design rather than a
	// precaution. The desk is a picture of screens that applications are running
	// on: the keyboard has to reach THOSE applications, and a window that takes it
	// is a window that stops the person using their own desk. The pointer is
	// worse -- it wanders onto the display the desk owns, where the picture is a
	// capture of somewhere else and so does not show where the mouse is. Measured:
	// the way out was unplugging the glasses.
	//
	// So the desk is driven from outside, which is what the system-wide shortcuts
	// and the menu-bar item are for, and the applications keep the keyboard and
	// the pointer they always had.
	//
	// Interactive is for a session on a desktop -- with -windowed, to try the
	// thing out -- where clicking the picture is the only way in.
	Interactive bool

	// NoGlobal leaves the system-wide shortcuts unclaimed.
	//
	// Claiming them takes them away from everything else on the machine for as
	// long as the session lasts, which is what makes them useful and what makes
	// them worth being able to refuse.
	NoGlobal bool

	// Screens are the desk's displays in ribbon order, so the band can follow
	// the pointer onto one of them. Nil turns that off: without the ids there is
	// no way to tell one of the desk's screens from the machine's own.
	//
	// It is the arrangement at START-UP. See [RunOptions.Showing] for what a
	// position is showing NOW, which is not the same thing the moment somebody
	// mirrors this Mac's own panel onto the ribbon.
	Screens []uint64

	// Showing answers, per ribbon position, the display it is showing right now,
	// or 0 for a position showing nothing.
	//
	// Without it the band follows the pointer onto the displays this program
	// MADE and nothing else -- so a position mirroring this Mac's own screen is
	// a position the band will not follow the pointer onto, and moving the mouse
	// there loses it in exactly the way following was written to prevent. The
	// list changes while it runs, so it is asked for rather than handed over.
	//
	// Nil falls back to [RunOptions.Screens].
	Showing func() []uint64

	// Snapshot, when set, is handed the first frame actually drawn — the picture
	// the glasses were shown. It is written by the caller, so this package never
	// decides where a capture of somebody's screens lands.
	Snapshot func(pix []byte, w, h int)
}

RunOptions are the choices a caller makes about the session itself, as opposed to the arrangement, which is the Plan's business.

type Screens

type Screens struct {
	// IDs identify the displays, in ribbon order.
	IDs []uint64
	// Virtual reports whether these were created by us. Never true here.
	Virtual bool
	// Why explains what happened, and is worth showing to a person.
	Why string
}

Screens is what the ribbon will show, and how it was obtained.

On Linux it is always the displays the machine already has. Creating one needs a compositor to agree, and no protocol an ordinary program can speak offers it — so unlike macOS, where a private CoreGraphics call makes real ones, here the ribbon carries what is already there. Fewer screens, everything else the same.

func Provide

func Provide(ctx context.Context, plan Plan, logf func(string, ...any)) (*Screens, error)

Provide gets the ribbon its screens.

func (*Screens) Add

func (s *Screens) Add(w, h int) (uint64, error)

Add creates one more display and appends it.

Nothing here can: creating a display is the one part of this that has no portable answer, and only macOS is wired up. A person is told rather than left pressing a key that does nothing.

func (*Screens) Close

func (s *Screens) Close() error

Close releases anything this created. There is nothing to release.

func (*Screens) Release

func (s *Screens) Release() error

Release is Close here: nothing was created, so nothing has to go.

func (*Screens) Remove

func (s *Screens) Remove(int) error

Remove gives back a display. Nothing here can: only macOS is wired up, and a person is told rather than left pressing a key that does nothing.

type SettingsOptions

type SettingsOptions struct {
	// Attached are the headsets to choose between. Nil asks the bus.
	Attached []glasses.USB
	// DisplayH is the height in pixels of the display the window will appear
	// on, so its type can be magnified to the same SIZE TO LOOK AT there. Zero
	// leaves the scale alone.
	//
	// A 5x7 bitmap glyph on a 2160-row panel is three millimetres tall, which
	// is how this window came to be reported as too small on an 8K screen.
	DisplayH int

	// Logf receives progress. A nil Logf says nothing.
	Logf func(string, ...any)
}

SettingsOptions are the choices a caller makes about the settings window.

type Shortcut

type Shortcut struct {
	Want hotkey.Combo
	Does Action
}

Shortcut is one system-wide combination and what pressing it does.

System-wide is the whole point. A virtual desktop in glasses is used while something else has the keyboard: the viewer is reading in one screen and wants the next one, without first clicking on a window they cannot see. A shortcut that only works when xrdesk is frontmost would be a shortcut for nothing.

func DefaultShortcuts

func DefaultShortcuts() []Shortcut

DefaultShortcuts are what the ribbon needs from the whole machine.

ONE prefix for all of them: Control+Option+Command. It was not so at first — the band was on ⌥⌘← and ⌥⌘→ while everything added later took the third modifier — and a person who had learnt the desk pressed ⌃⌥⌘← for the band and got nothing at all. A set of shortcuts with two prefixes is a set nobody can remember, and the one that gives way is the one with fewer keys on it.

⌥⌘← and ⌥⌘→ were also Safari's tab navigation, which the desk was quietly taking for the length of a session.

There is no TOGGLE here any more, and that is the point of the set: every one of these is pressed BLIND, so each says what it does. ⌃⌥⌘↑ shows the screens, ⌃⌥⌘↓ shows what is running on them, and leaving is the bare Escape the gallery holds while it is up. ⌥⌘Space used to toggle the screen gallery, which meant "open" or "close" depending on something the person could not see -- and it was the Finder's search window on a stock macOS, so it never got the keys it asked for either. The toggle remains on `g` INSIDE the window, where the state is in front of you, and a settings file can still put it back.

See DefaultLadder, and Hotkeys.Describe for what was actually granted.

func GalleryShortcuts

func GalleryShortcuts() []Shortcut

GalleryShortcuts are the BARE keys a gallery claims for as long as it is up.

A person looking at a grid of screens or of applications should not have to hold three modifiers to walk it -- "in the gallery, moving with just the arrows ought to be enough", which is exactly right. So while a gallery covers the view, the arrows, Return and Escape mean what they look like they mean.

They are claimed system-wide, because the desk's window deliberately does not take the keyboard. That is a serious thing to do to a machine — a bare arrow claimed for ever would break typing everywhere — so it is done only while a gallery is up and undone the moment it closes. go-macos/hotkey v0.6.0 asks a caller to say so with Options.BareKey rather than allowing it by accident.

Nobody is typing into anything while a gallery covers their view.

type Slant

type Slant struct {
	// Screen is the index of the screen on the band. Like a blit's, it is not
	// unique within a frame: a screen straddling the seam comes back twice.
	Screen int
	// Dst is the bounding box in the canvas. Every column of it is written
	// between its own two rows; the rest of the box is left alone.
	Dst stereo.Rect
	// Cols is one entry per column of Dst, in order.
	Cols []SlantCol
}

A Slant is one screen drawn TURNED TOWARDS THE VIEWER: a flat panel rotated about the vertical axis, projected.

It is what a desk of monitors looks like. The one in front is square on; the ones beside it are angled in, so their far edge is shorter than their near edge and their surface is foreshortened. That is not the curvature this package deleted -- a curve bows the screen you are looking AT, which argues with the depth the glasses already present. A rotation leaves every screen flat and only changes which way it faces, which is the difference between a bent screen and a turned one.

The shape is a TRAPEZOID and that is not an approximation: a plane rotated about the vertical projects to one exactly. Vertical edges stay vertical -- the rotation does not move a point up or down -- so a source column lands on a destination column, and the only things that vary along the width are how tall that column is and which source column it reads. Nothing here needs a warp table, a matrix per pixel, or a panorama.

At zero rotation the two vertical edges are the same depth, so the height is constant and the source columns are evenly spaced: the trapezoid IS the rectangle this package drew before, byte for byte. That is asserted rather than asserted-in-a-comment; see TestASlantAtZeroDegreesIsTheOldRectangle.

type SlantCol

type SlantCol struct {
	// Src is the source column, or -1 for a column with no source at all --
	// which happens where a rotated panel has turned past the edge of the
	// source. Such a column is left as background rather than stretched.
	Src int32
	// Y0 and Y1 are the destination rows this column covers, [Y0, Y1).
	Y0, Y1 int32
}

SlantCol is one destination column of a slant: which source column it shows, and the destination rows it covers.

Rows rather than a scale, because the scale is what a reader would have to work out from them and the rows are what the drawing loop wants. Y0 and Y1 are absolute canvas rows, so a column can be clipped at the top or the bottom of the canvas without the loop knowing what clipping is.

type Source

type Source struct {
	Pix    []byte
	W, H   int
	Stride int
}

Source is one captured screen's pixels.

Stride is carried rather than assumed because it is NOT width*4 in general: a 400-pixel-wide capture came back from ScreenCaptureKit with rows padded to 1664 bytes, while a 3840-pixel-wide one had no padding at all. Code that assumes either is wrong half the time, and wrong here means a picture that shears progressively down the screen.

type Strip

type Strip struct {
	// contains filtered or unexported fields
}

A Strip is the ribbon with its screens left FLAT.

The curved ribbon puts each screen on the surface of a cylinder, which is geometrically honest and, worn, buys nothing: a screen the viewer is looking straight at is drawn with a bow in it, and the bow argues with the depth the glasses are already presenting. It also costs a projection — an equirectangular panorama and a per-pixel warp, 2.8 ms of a 16.6 ms frame — to produce a picture whose whole purpose is to look like a flat screen.

So the screens are laid side by side on a flat band and the band slides.

The scale is the only thing that has to be decided, and the plan decides it: ONE VIEW IS ONE FIELD OF VIEW. Everything else follows from the ribbon's own placement — where each screen sits, how wide it is, and therefore how much space is left between two of them. There is no separate gap to set and get wrong, because the gap is not this type's to invent.

The band closes on itself: walking right past the last screen arrives at the first, and a screen straddling that join is drawn as two pieces, one against each edge.

func NewStrip

func NewStrip(placed []ribbon.Placed, totalPx, srcW, srcH, viewW, viewH int) (*Strip, error)

NewStrip lays the ribbon's screens out flat for a view of viewW x viewH.

totalPx is how long the whole band is, in pixels. That single number sets the scale: a screen's arc becomes its width, and the arc between two of them becomes the space between them. Nothing here needs a field of view — the band is flat, so how large a screen LOOKS is the optics' business and not this package's.

func (*Strip) Frame

func (s *Strip) Frame(dst []ribbon.Blit, offset int) []ribbon.Blit

Frame appends the blits for one view of the band and returns the extended slice. Passing dst[:0] of the previous frame's slice reuses the storage, and the frame then allocates nothing at all.

The destination rectangles are in VIEW coordinates and already clipped, so the canvas a caller composes into is the size of the picture rather than of a panorama it would then have to be a projection of.

func (*Strip) Fullscreen

func (s *Strip) Fullscreen(dst []ribbon.Blit, i int) ([]ribbon.Blit, error)

Fullscreen appends the blit for one screen filling the whole view.

It is a real promotion only when the screen is narrower than the view. At one screen per view it changes nothing, which is the right answer rather than a missing feature: there is nowhere for a screen already filling the glasses to grow to.

func (*Strip) Offset

func (s *Strip) Offset(yaw float64) int

Offset turns a yaw in radians into the point on the band the viewer faces.

The navigator thinks in angles because a ribbon is a circle, and it keeps working — the gallery, the focus, the shortest way round — without knowing that the picture is flat. A full turn is the whole band.

func (*Strip) Screens

func (s *Strip) Screens() int

Screens is how many screens are on the band.

func (*Strip) SetSourceWidths

func (s *Strip) SetSourceWidths(w []int)

SetSourceWidths gives screens their own source widths.

A screen mirroring a display this program did not make is not the shape of the glasses -- this Mac's panel is 1.547 against the band's 1.778 -- so its capture is that shape too, and the arc it takes on the ribbon was worked out from the same number. Mapping it through the band's width instead would stretch it across a quad that is not its shape, which is the very thing giving that screen its own arc was for.

A nil or short slice, or an entry of zero, leaves that screen on the width every other screen has.

func (*Strip) Toward

func (s *Strip) Toward(yaw float64, focus int) float64

Toward is how far the band has moved from the focused screen towards its neighbour, in screens: 0 is the focused screen centred, 0.5 half way to the next one, -0.25 a quarter of the way back to the last.

It exists so that the turned band (Fan) and the flat one agree about where the band IS, and it is expressed RELATIVE TO THE FOCUS on purpose. An absolute position along the band would need the screens to be in band order, and they are not: a ribbon may put screen zero anywhere on the circle, and this desk's starts at 210 degrees. Asking "how far past the screen the navigator says we are on" needs no such assumption -- and taking each screen's place from the ribbon rather than assuming an even spread is what keeps this in step with the navigator, which it once was not.

func (*Strip) Width

func (s *Strip) Width() int

Width is the whole band, in pixels.

type Tray

type Tray struct {
	// contains filtered or unexported fields
}

Tray is the desk's menu-bar item, and the two ways it can live.

func OpenTray

func OpenTray(logf func(string, ...any), actions chan<- Action) (*Tray, error)

OpenTray puts the item in the menu bar and sends what is chosen to actions.

THE ITEM IS go-widgets/tray's, NOT THIS PACKAGE'S. It was mine for a while, built straight on NSStatusItem, and that was a mistake with a symptom: a status item needs an NSApplication whose run loop is RUNNING, and this program has none until a display has been chosen and a window opened on it. Lending AppKit slices of the main thread drew the icon and never opened its menu -- "l'icon est visible dans le tray mais je n'ai aucun menu" -- because a menu is not drawn, it is tracked, and tracking needs the loop.

tray owns that distinction: Run when a program has no loop of its own and Attach when it does. Both are its backends' business on three platforms rather than this package's on one.

The send is NON-BLOCKING, and that is the whole of the design. A menu handler runs while the desk may be stopped -- the settings window is up, or the ribbon is between sessions -- so nobody is reading. A blocking send would leave that handler parked for as long as the window is open and then replay every click at once. A dropped choice is logged and forgotten, which is what a person clicking a menu with nothing happening expects: the next click, not the last five.

func (*Tray) Attach

func (t *Tray) Attach() error

Attach adds the item to a loop somebody else is already running, and returns at once. It is for the desk: the window owns the main thread from then on.

func (*Tray) Close

func (t *Tray) Close() error

Close takes the item away and stops the icon following anything.

func (*Tray) Hold

func (t *Tray) Hold() error

Hold runs the item AND the platform's main loop, and returns when Release is called.

It is for the wait: there is no window yet, so nothing else is driving AppKit, and without a loop the item is an object nobody can open. It must be called on the main thread.

func (*Tray) Release

func (t *Tray) Release()

Release stops the loop Hold is running, so the caller can go on to open a window and drive its own.

func (*Tray) ShowShortcuts

func (t *Tray) ShowShortcuts(keys map[Action]hotkey.Combo)

ShowShortcuts puts the combination that was GRANTED on each row it belongs to, and rebuilds the menu.

Granted rather than asked for, which is why this is a method called later instead of an argument to OpenTray. The item is made once for the whole process and outlives every session, while the shortcuts are claimed when a session starts -- and a claim is not a grant: the ladder substitutes when a combination is taken, so a menu built from what was ASKED for would print a combination that does nothing. A row whose action was never granted keeps its bare label rather than a lie.

The rows a person can reach with the keyboard say so here rather than in the settings window, which is where they used to be listed: a menu row and the key that does the same thing belong on the same line, and moving them takes most of a page out of a window that had grown too tall for a laptop screen.

func (*Tray) State

func (t *Tray) State() *mvvm.Observable[TrayState]

State is what the icon follows.

An Observable rather than a setter, because that is how anything in this fleet says "this changed" across a boundary, and because tray.BindIcon takes one: the icon then follows the desk without either knowing about the other.

type TrayRow

type TrayRow struct {
	Title  string
	Key    string
	Action Action
	// Symbol is the system symbol drawn to the left of the label, by the name
	// the platform knows it under -- an SF Symbol on macOS. Empty leaves the
	// row text-only, which is what a separator is and what every row was.
	//
	// A NAME RATHER THAN A PICTURE, for the same reason the menu-bar item
	// carries one: a symbol is the platform's own, so it arrives at the weight
	// and in the ink of every other menu in the bar, follows a light or dark
	// appearance without being asked, and costs no bytes in the binary. An icon
	// pack would be four hundred pixels of somebody else's drawing beside
	// nineteen of Apple's -- which is exactly the report the menu-bar icon was
	// already fixed for once.
	//
	// A platform with no such symbol draws the row as it always did.
	Symbol string
}

TrayRow is one row of the menu: what it says, its key equivalent, and the action it asks for. An ActionNone row is a separator.

func TrayRows

func TrayRows() []TrayRow

TrayRows is the menu, in order.

Deliberately short. It is not a second copy of the keyboard: turning the band and moving in the gallery are done while looking at the band, by feel, and a row in a menu bar for either would be a row nobody uses. What is here is what a person cannot do from inside the glasses.

type TrayState

type TrayState int

TrayState is what the menu-bar icon says about the desk.

const (
	// TrayWaiting is the desk with no screens up: waiting for a pair of
	// glasses, or between sessions.
	TrayWaiting TrayState = iota
	// TrayRunning is a desk on a headset, with screens.
	TrayRunning
)

Directories

Path Synopsis
cmd
deskchaos command
Command deskchaos runs the desk over and over, breaks it on purpose, and checks what it leaves behind.
Command deskchaos runs the desk over and over, breaks it on purpose, and checks what it leaves behind.
deskcheck command
deskcheck says what this machine can actually do, and proves each answer by doing it rather than by reporting a capability bit.
deskcheck says what this machine can actually do, and proves each answer by doing it rather than by reporting a capability bit.
macapp command
Command macapp assembles xrdesk into a macOS application bundle.
Command macapp assembles xrdesk into a macOS application bundle.
standin command
Command standin holds a virtual display open under a headset's name, so the desk can be exercised with no glasses on the desk.
Command standin holds a virtual display open under a headset's name, so the desk can be exercised with no glasses on the desk.
xrdesk command
xrdesk shows several screens on a 360° ribbon inside AR glasses.
xrdesk shows several screens on a 360° ribbon inside AR glasses.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL