diff --git a/AGENTS.md b/AGENTS.md index 2bdb41d..4ac4a83 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,6 +19,7 @@ ELYTerminal. A terminal on GPUI and Ely components. `PRD.md` says what it does; - keys as real AppKit events: `a`, `ctrl-c`, `lopt-f` and `ropt-f` (left and right Option), `cmd-v`, `repeat-up` (AppKit's repeat flag); `down:` or `up:` before a key posts only its press or its release, so a modifier may change between them; - input method calls: `mark:é` composes, `commit:é` types; - `wait` pauses a second, so a program can start before the keys after it; + - `snap` writes each child window's picture at that moment, `PNG.childN.snapK.png`, so a child that moves shows its frames; - `activate` and `deactivate` the app, which takes real keystrokes while active: keep the two specs adjacent. macOS may delay or refuse the activation; each `shot: typed` line says whether the app was active; - `to:settings` and `to:main` aim the specs after them, and the picture, at that window, which becomes the app's key window, since AppKit sends keys there; the app stays inactive, and gpui sends an inactive window no focus or blur events, so press Enter to commit a field. AppKit gives an inactive app's key that a menu item holds to the menu, and gpui sends the item's action to the main window, which an inactive app lacks: only an app-wide action runs, such as Quit, Settings or the font's size; - mouse gestures handed to gpui's view, at points from the content's top-left, modifiers as for keys: `click@x,y`, `click2@`, `click3@`, `rclick@`, `mclick@`, `drag@x,y@x,y`, `move@x,y`, the steps `ldown@`, `lup@`, `ldrag@`, `rdown@`, `rup@`, `mdown@`, `mup@`, and `scrollup@x,y@lines`, `scrolldown@x,y@lines`. @@ -33,7 +34,7 @@ ELYTerminal. A terminal on GPUI and Ely components. `PRD.md` says what it does; - `build.rs`: on Windows, the icon into the executable as resource 1, through `assets/icons/elyterminal.rc`. `.cargo/config.toml`: Windows builds carry the C runtime. `rust-toolchain.toml`: the toolchain rustup installs. - `src/main.rs`: start. `src/lib.rs`: the modules, public so tests and examples reach them. `src/app.rs`: fonts, settings, integration, then the main window, at the size the settings keep, which it keeps as it settles. `src/menus.rs`: the menu bar, the keys of Quit, the font's size and Minimize, and what they do. `src/blur.rs`: what the window shows of the desktop, from the opacity and blur settings, set as they change; `blur/macos` gives gpui's NSVisualEffectView a material that blurs; `blur/wayland` asks a Wayland compositor for blur behind the window's surface through `ext_background_effect_v1`; `blur/x11` names the window's pane in `_KDE_NET_WM_BLUR_BEHIND_REGION`. `src/workspace.rs`: the window's content, Ely's `ColumnShell` with the sessions, terminal and right columns, their toggles, the right column's Git and Files switch, the title, and the sessions' lifecycle. `src/workspace/widths.rs`: the side columns' widths, which a drag on their border sets and the settings keep once it rests. `src/workspace/events.rs`: what a session's terminal reports: title, folder, bell, the state its row shows, the program's exit; and a press on a notification a session posted. `src/sessions.rs`: a session and its row, with the mark of its status and what hover shows of it; `sessions/orb` draws the mark, 3 by 3 dots on one clock. `src/files.rs`: the Files panel, the shown session's folder as Ely's `Tree` with Git's marks. `src/git.rs`: git through its command line: Files' marks, and the Git panel's status, stage, unstage, discard, commit and log; `src/git/diff.rs` reads git's diff into Ely's stretches. `src/git/panel.rs`: the Git panel's state and actions; `src/git/panel/view.rs`: how it draws. -- `src/pet.rs`: Ely, peeking over the main window's top edge from a window of her own while the settings want her; `pet/faces` draws her faces as SVG from the icon's paths, `pet/look` reads her mood from the sessions' marks and the face she makes at each moment of it, and `pet/macos`, `pet/windows` and `pet/x11` keep her window on the main window's edge where the platform does not. +- `src/pet.rs`: Ely, peeking over the main window's top edge from a window of her own while the settings want her; she draws at every frame the display gives her window; `pet/rig` draws her from the icon's paths in layers of one color, each moved by a matrix of its own, as the films rig her; `pet/look` reads her mood from the sessions' marks and her pose at each moment of it, `look/curves` the timings of her moves; and `pet/macos`, `pet/windows` and `pet/x11` keep her window on the main window's edge where the platform does not. - `src/settings`: the model, its ranges, and `SettingsStore`, the global that loads, checks and saves `settings.json`; `window` is the settings window, `window/appearance` its Appearance page, `window/terminal` its Terminal page. `tests/fonts.rs` brings its own main: gpui builds its Mac platform only on the main thread. - `src/themes`: built-in terminal themes, the chrome palette each maps to, and `apply`, which dresses the app for the system's appearance; `omarchy`, on Linux, wears Omarchy 4's current theme and follows its switches. - `src/term`: the terminal engine. `pty` starts a program through alacritty's event loop; `tap` wraps the pty so its reader scans OSC 0, 2, 7, 9, 99, 133, 777 and 7501, the title stack's CSI 22 t and 23 t, full resets, the mouse modes and, on Windows, win32-input-mode (`tap/modes`) before the parser; `titles` keeps the title a session shows, read in the output's order; `marks` reads OSC 133's prompt marks; `status` reads OSC 7501 and keeps a terminal's records; `notify` reads OSC 9, 777 and 99, the notifications and OSC 9;4's progress, and gathers OSC 99's chunks; `reports` is the view's side of what programs report: it keeps OSC 7501's records, maps OSC 9;4, answers OSC 99's query and alive, and posts the system notifications its session posts; `prompts` turns each mark into an OSC 8 link the parser keeps in a prompt's cells, and finds the prompt to jump to; `herdr` follows herdr's focused pane and its agents' states through herdr's socket while herdr's client runs in the session, and tells the view herdr leads, so herdr opens its links: `herdr/unix` finds the client leading the pty's foreground, `herdr/windows` under the session's shell, and asks its named pipe. `unix` starts the program itself, so a failed `chdir` fails the start; Windows uses alacritty's ConPTY. `shell` writes the integration scripts at start and builds each shell's launch. `frame` reads the grid into styled rows, `boxes` turns box drawing, block elements and powerline arrows into rects and polygons (`boxes/outline` strokes curves and diagonals into polygons), `ink` resolves colors, `paint` draws rows, decorations and the cursor, `view` is the entity: pty events, metrics, blink, resize. `zoom` holds the points Bigger and Smaller add to the Size setting, unsaved. `keys` turns key events into bytes, the kitty protocol and, in `keys/legacy`, xterm's encoding, with no gpui types; `input` is the view's key listeners, the bindings that keep Tab and F6 from Ely's focus moves, and its IME handler; `mac` reads the Option side, the repeat flag and the key code of AppKit's current event, and the key's own character from the layout, as gpui reads key names; it keeps held keys repeating, and hands Command key-ups to the focused view. `win32` sends a key as Windows' own record, win32-input-mode, and finds a key's own character in Windows' layout. `mouse` encodes mouse reports, with no gpui types; `links` finds the link at a cell; `pointer` is the view's mouse listeners: selection, reports, wheel, links, copy and paste. `find` is the find box: Ely's `FindWidget`, and Ely's `find_all` over the grid's shown text. @@ -107,4 +108,4 @@ ELYTerminal. A terminal on GPUI and Ely components. `PRD.md` says what it does; - 2026-10-17: a session posts system notifications through gpui's, which the owner asked for. At start `set_app_identity` names the app: on Windows that sets the process's AppUserModelID, which the taskbar groups its windows by too, so a pin made before may show apart, and the first notification writes the app's name under `HKCU\Software\Classes\AppUserModelId`, as gpui needs for an app without a package; on macOS gpui posts only from the app bundle, asks for leave at the first, and logs once, under the `gpui_macos` target, that it posts nothing outside one; on Linux it posts over D-Bus through notify-rust. Each terminal view posts its own, in the order its output asks, so OSC 99's answers keep their place before a DA1 sent after them: when its row's mark turns to waiting, done or error, at each turn, as the row's waiting mark is set, the hover's line for the record its body; when a command C started ends, `Command finished`, or `Command exited with N`, so a prompt drawn again, as macOS's bash 3.2 draws one with its D at a resize, posts nothing, and bash 3.2, which sends no C, posts no command's end, as Ghostty's integration, which skips it, posts none; and when a program asks through OSC 9, 777 or 99, its title and body the body, each line shown as text. It posts only while its window is not active or it is not the session shown, which the workspace tells it, one every 3 seconds at most, a later one logged and dropped; the title is the program's title, else the app's name, as the row names the session, which the tap reads in the output's order, so a title set before a notification in the same output names it. The session's own share a tag, so a new one takes the old one's place where the system allows, as an OSC 99 id does; OSC 9, 777 and OSC 99 without an id each take their own; a session keeps its last 64. A press goes to the window whose session posted it: it shows the window and the session, unless OSC 99 said `a=-focus`, and tells a program that asked `a=report`, `OSC 99 ; i=ID ;`; a press whose record went, a twin Linux stacked and pressed before or one past the 64 kept, still shows its session and tells no program. macOS brings the app forward at any press, `a=-focus` or not, and on Wayland gpui asks for the window with a serial the compositor likely refuses, so KWin and Mutter mark it as asking for attention instead. gpui hears of no close on any system, so `c=1` hears `p=close;untracked` once posted, and alive lists the ids posted and not yet pressed, closed or expired. On macOS and Windows a close takes the notification back, and `w` over 0 takes it back once it runs out, its session gone or not, unless a later post holds its tag; gpui on Linux closes and replaces none, so there a close and `w` pass by, logged, a closed one stays alive, and an update posts beside the old one. `o` passes by: each notification posts only while unseen, which is kitty's invisible, so the query answers `a=focus,report:o=invisible:p=title,body,close,?,alive:w=1`, on Linux `a=focus,report:o=invisible:p=title,body,?,alive`, never with the `c` that close events would need. Windows' toasts are silent, as macOS's are; on Linux the notification daemon decides, and reads the body as markup, so its `&`, `<` and `>` go as entities. - 2026-10-18: herdr's shortcuts, which a user found dead, as kitty and Ghostty send them. herdr 0.9.5 asks for kitty's disambiguate, events and alternates on macOS and Linux, and takes `cmd+shift+ctrl+[` as `[` with Shift, Ctrl and Super, `CSI 91;14u`, the symbol as its alternate, `CSI 91:123;14u`, as Ghostty sends it; gpui named the key `{` and cleared Shift, so ELYTerminal sent `CSI 123;13u`, which herdr matches only to `cmd+ctrl+{`. Under kitty's flags a key Shift made a symbol of now goes as its own character with Shift, the symbol its alternate; legacy keys keep fixterms' symbol without Shift, as Ghostty's do. On macOS the key's own character comes from `UCKeyTranslate` on the event's key code, in the layout gpui reads: Command's while Command is down or while the plain one types no ASCII; AppKit's `charactersByApplyingModifiers:` ignores the layout for events built in-process. On Windows herdr asks for no kitty flags and reads the console's key records, and ConPTY asks every terminal for win32-input-mode, `CSI ? 9001 h`; so, as Windows Terminal does, each key that types no text goes as `CSI Vk ; Sc ; Uc ; Kd ; Cs ; Rc _`: the virtual key from gpui's name and the layout (`VkKeyScanW`), its scan code, what Windows types for it (`ToUnicode`, a dead key left as it is), down or up, and the modifier keys, locks and enhanced block from `GetKeyState`. kitty's flags go first, as in Windows Terminal; text stays text, so a repeat or a release goes as a record where its press did, a repeat that types after its modifiers came up too, and a release goes so only there; a full reset ends the mode, as Windows Terminal's does. A key that is down is known by its code, AppKit's key code on macOS and the virtual key on Windows, since gpui names a release by the modifiers left down: a release after Shift came up still goes out. Linux has only gpui's name. A console record holds no Windows key, so no Windows terminal passes herdr a `cmd` binding, and a keypad key goes as its main key, since gpui names it so. On Linux gpui keeps neither the key code nor its xkb state, and names a non-ASCII key by its place on a US keyboard, so a key's own character cannot be found: Shift with a symbol and Ctrl, Alt or Super goes as the symbol without Shift, which herdr matches to a binding on the symbol, `ctrl+{`. - 2026-10-18: inside herdr, herdr opens its panes' links, as the owner's Cmd-press and Ctrl-press ask. herdr 0.9.5 wraps a long URL at its pane's edge with no wrap flag and writes its panes' OSC 8 links to the terminal without their targets, so ELYTerminal's own finder opened a cut URL or nothing; herdr itself opens the link under a Ctrl-press, whole from its own pane, a wrapped URL or an OSC 8 target, and underlines it while Ctrl is held. So while herdr's client leads the session and tracks the mouse, every link press goes to herdr as the mouse report it is, and on macOS herdr hears Cmd as Ctrl, the wheel aside. herdr's rules come with it, by its design: while it takes the mouse it keeps its panes' OSC 8 targets from the terminal, so that it alone answers Ctrl-press, and it opens only `http` and `https` links; a `file://` link inside herdr stays shut, since the text the terminal sees may be the label of another link. Shift with the press, which takes the mouse back from a program, is the way past herdr its docs name: the terminal then opens what it sees, a `file://` link too. With herdr's mouse capture off, herdr writes its links' targets out and the terminal opens them itself. Elsewhere ELYTerminal opens the link itself, as before. -- 2026-10-18: Ely peeks over the main window's top edge, which the owner asked for after the 0.1.3 film, her head and fingers drawn from the icon's own paths and cut at the edge she holds, in a transparent window of her own, 112 by 107 points with 16 above her for her hops, 140 in from the pane's left edge, less on a pane too narrow for that, so she holds its edge at any width, and the main window seems to hide the rest of her. Her window never takes the keys from the terminal: on macOS her panel becomes key only if needed, which gpui's view never asks; on Windows her window answers a press's activation with none, every other message passed on to gpui's own procedure; on X11 and Wayland a press moves no focus. gpui moves no window and places its popups only on Wayland, so each platform keeps her there its own way: on macOS gpui's popup panel is made the main window's child, at its level and in its Spaces, without a shadow, so AppKit moves it and hides it with the window; on Windows gpui's popup is owned by the main window and loses the topmost place gpui gives it, so it stays just above the main window and hides with it; gpui makes it an overlapped window, which Windows gives a caption and a frame, and opens it as large as the display when the middle of the box asked for lies off every display, as hers does over a window near the screen's top: in 0.1.4 a user found her at the bottom of a framed window the display's size, which took the clicks meant for the windows behind; so it becomes a bare popup, without caption, frame, corners or shadow, and the glue posts her window her bottom left corner on the pane's top edge as the main window moves, since Windows sends a new size at once and gpui hears it only outside its own updates, as gpui defers its own resize; her window sizes her at the DPI it has when the place lands, so her height is her own DPI's and her bottom the pane's top, whatever the two windows' DPI, and a place posted before her DPI changed sizes her at the new one; at a new DPI gpui takes the rect Windows suggests, and once the move that brought it is done she takes her box at that DPI on the corner her last place gave her, while that DPI is still hers and the box lies mostly on a display of it, as Windows reckons a window's display, else Windows' rect stands; so a keep changes no DPI and posts no other, where between two displays a corner kept always, or a keep whose DPI had passed while places piled up, set her DPI back, and back again; on X11 gpui's popup, which no window manager places, is moved onto the pane, stacked just above the window manager's frame as the window moves or comes forward, and unmapped while it hides; on Wayland she is gpui's anchored popup, an xdg_popup on the pane's top edge, which the compositor keeps there; gpui moves a popup's anchor never, so a new width that moves her place opens her again. She opens with the sessions' mood, and her face follows the most pressing of their marks: a program that waits surprises her, with a mark and two hops; an error worries her, with a shake; a finish cheers her for 4 s, with a hop; work keeps her eyes on the terminal; calm, she blinks and glances, and dozes after 5 minutes; a press winks at you. Each face is an SVG drawn once, at three times her size so Retina draws her sharp, and she draws again only when her face changes. Settings › Terminal › Ely turns her off; she is on by default. +- 2026-10-18: Ely peeks over the main window's top edge, which the owner asked for after the 0.1.3 film, her head and fingers drawn from the icon's own paths and cut at the edge she holds, in a transparent window of her own, 112 by 107 points with 16 above her for her hops, 140 in from the pane's left edge, less on a pane too narrow for that, so she holds its edge at any width, and the main window seems to hide the rest of her. Her window never takes the keys from the terminal: on macOS her panel becomes key only if needed, which gpui's view never asks; on Windows her window answers a press's activation with none, every other message passed on to gpui's own procedure; on X11 and Wayland a press moves no focus. gpui moves no window and places its popups only on Wayland, so each platform keeps her there its own way: on macOS gpui's popup panel is made the main window's child, at its level and in its Spaces, without a shadow, so AppKit moves it and hides it with the window; on Windows gpui's popup is owned by the main window and loses the topmost place gpui gives it, so it stays just above the main window and hides with it; gpui makes it an overlapped window, which Windows gives a caption and a frame, and opens it as large as the display when the middle of the box asked for lies off every display, as hers does over a window near the screen's top: in 0.1.4 a user found her at the bottom of a framed window the display's size, which took the clicks meant for the windows behind; so it becomes a bare popup, without caption, frame, corners or shadow, and the glue posts her window her bottom left corner on the pane's top edge as the main window moves, since Windows sends a new size at once and gpui hears it only outside its own updates, as gpui defers its own resize; her window sizes her at the DPI it has when the place lands, so her height is her own DPI's and her bottom the pane's top, whatever the two windows' DPI, and a place posted before her DPI changed sizes her at the new one; at a new DPI gpui takes the rect Windows suggests, and once the move that brought it is done she takes her box at that DPI on the corner her last place gave her, while that DPI is still hers and the box lies mostly on a display of it, as Windows reckons a window's display, else Windows' rect stands; so a keep changes no DPI and posts no other, where between two displays a corner kept always, or a keep whose DPI had passed while places piled up, set her DPI back, and back again; on X11 gpui's popup, which no window manager places, is moved onto the pane, stacked just above the window manager's frame as the window moves or comes forward, and unmapped while it hides; on Wayland she is gpui's anchored popup, an xdg_popup on the pane's top edge, which the compositor keeps there; gpui moves a popup's anchor never, so a new width that moves her place opens her again. She opens with the sessions' mood and moves all the time, as the films move her, which the owner asked for after 0.1.4, where she stood still but for a press. She is rigged from the icon's paths as the films rig her, in layers of one color, each an SVG mask gpui rasterizes once at its size and moves with a matrix of its own after its pixels are placed, so she moves between pixels; her head bobs with her breath while her neck, collar and hands stay on the edge, her side locks sway, her ahoge breathes, and she blinks on her own, at times twice. Her mood sets her act: calm, she glances left, then right, her head after her eyes, and dozes after 5 minutes, her eyes falling shut, her head and ahoge drooping, zs floating up; a program that works runs her eyes along the terminal's lines while her fingers tap by turns; one that waits on you widens her eyes, opens her mouth, rocks a mark beside her and hops her twice every 2.6 s; an error raises her brows and shakes her, more softly every 4.5 s after, while a drop runs down; a finish cheers her for 4 s with smiling eyes, a hop and sparkles; a press winks at you while a heart rises. Each act begins with her eyes opening and her mouth popping, and a change of act eases her from her last pose in 0.3 s, the marks she leaves fading. She draws at every frame the display gives her window while it shows, and gpui gives a hidden window none; her time is folded into each move's round in f64, so weeks of one mood keep her steps fine. Settings › Terminal › Ely turns her off; she is on by default. diff --git a/PRD.md b/PRD.md index 4fb5451..b6c1b48 100644 --- a/PRD.md +++ b/PRD.md @@ -70,7 +70,7 @@ Ghostty is the bar for what the terminal does. ELYTerminal owns its engine on `a - The window opens at the size it was left at, on macOS, Windows and Linux: the size is kept in settings once it settles; a maximized or full-screen window keeps the size from before; a kept size past the screen shrinks to it; the window is no smaller than 100 by 100, the least the settings keep. - The title shows the active session's title, and no other text. - The pointer over the terminal is the arrow. -- Ely, the icon's girl, peeks over the window's top edge from behind it, on macOS, Windows and Linux: her face shows how the sessions are doing, waiting, an error, work, a finish or calm, and a press pets her. A switch in Settings, Terminal, turns her off. +- Ely, the icon's girl, peeks over the window's top edge from behind it, on macOS, Windows and Linux. She moves all the time, as she does in the films: she breathes, sways and blinks, and how the sessions are doing, waiting, an error, work, a finish or calm, moves her. A press pets her. A switch in Settings, Terminal, turns her off. - The title bar is transparent, as Ghostty's: the window's background, opacity and blur run under it, so it wears the terminal's color. - Every control in the title bar takes its own press on every platform, the window buttons Ely draws on Windows and Linux too. diff --git a/README.md b/README.md index 6164118..1854822 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ The app writes its log to stderr. `RUST_LOG=elyterminal=debug` writes more; `tra - The terminal runs on `alacritty_terminal`: 256 colors and truecolor, bold, dim, italic, five underline styles, wide characters and combining marks. Box drawing, block elements and powerline shapes are drawn as geometry, so lines join across cells. Keys follow xterm and the kitty keyboard protocol, and on Windows win32-input-mode, as Windows Terminal sends them, so herdr's shortcuts work; input methods compose at the cursor; programs can track the mouse; pastes are bracketed when a program asks; OSC 8 and plain links open with the link press below; inside herdr, herdr opens them, web links only, whole where its pane wraps them, and with Shift the terminal opens what it sees; find searches the scrollback. - Shell integration for zsh, bash, fish and PowerShell: each prompt reports its folder and title, and OSC 133 marks each prompt, each command and its exit status. -- Ely peeks over the window's top edge. Her face follows your sessions: she startles when a program waits on you, frets at an error, cheers a finish, watches the terminal while a program works, and dozes when all is calm for long. Press her and she winks. Settings › Terminal › Ely turns her off. +- Ely peeks over the window's top edge and moves all the time: she breathes, sways and blinks. She follows your sessions: she hops when a program waits on you, shakes at an error, cheers a finish, reads along while a program works, and dozes when all is calm for long. Press her and she winks. Settings › Terminal › Ely turns her off. - Sessions: new, close and switch. A bell marks a hidden session's row and makes no sound. A row's mark shows what its programs report through OSC 7501, OSC 9;4 or herdr: waiting on you, an error, work with its progress, done or idle, else a failed last command. The mark is 3 by 3 dots: work ripples and waiting dips while they last. Hover over the mark lists each report. - Notifications: while the window is in the background or the session hidden, a session that starts waiting, finishes or fails, and a program's OSC 9, 777 or 99, post a system notification, one a session every 3 seconds at most; a press shows the session. On macOS only the app bundle posts them, not `cargo run`. - Cmd-Up and Cmd-Down, Ctrl-Shift-Up and Ctrl-Shift-Down off macOS, jump to the prompt before or after the view's top. diff --git a/examples/shot/main.rs b/examples/shot/main.rs index 5e5f2f3..5a6057c 100644 --- a/examples/shot/main.rs +++ b/examples/shot/main.rs @@ -38,11 +38,25 @@ fn main() { number.expect("the window lives").expect("a window number") }; let mut aim = main; + let mut snaps = 0; capture::front(number(aim, cx)).expect("the window on screen"); cx.background_executor().timer(Duration::from_secs(1)).await; for spec in &keys { match spec.as_str() { "wait" => cx.background_executor().timer(Duration::from_secs(1)).await, + "snap" => { + snaps += 1; + let frames = capture::frames(number(aim, cx)).expect("the frames"); + for (index, (child, _)) in frames.into_iter().skip(1).enumerate() { + let path = + out.replace(".png", &format!(".child{index}.snap{snaps}.png")); + capture::snapshot(child, std::path::Path::new(&path)) + .expect("a child's picture"); + log::info!( + "shot: snap {snaps}, child window {child}, wrote {path}" + ); + } + } "to:main" => { aim = main; capture::front(number(aim, cx)).expect("the window on screen"); diff --git a/src/pet.rs b/src/pet.rs index 9f3b301..52e6cb6 100644 --- a/src/pet.rs +++ b/src/pet.rs @@ -1,27 +1,20 @@ //! Ely, the icon's girl, peeking over the main window's top edge from a window of her own. -mod faces; mod look; #[cfg(target_os = "macos")] mod macos; +mod rig; #[cfg(windows)] mod windows; #[cfg(target_os = "linux")] mod x11; -use std::{ - cell::RefCell, - f32::consts::PI, - rc::Rc, - sync::Arc, - time::{Duration, Instant}, -}; +use std::{cell::RefCell, rc::Rc, time::Instant}; use gpui::{ - Animation, AnimationExt as _, App, AppContext as _, Bounds, Context, Image, InteractiveElement, - IntoElement, MouseButton, ParentElement, Pixels, Render, Styled, Subscription, Task, Window, - WindowBackgroundAppearance, WindowBounds, WindowHandle, WindowKind, WindowOptions, div, img, - point, popup, px, size, + App, AppContext as _, Bounds, Context, InteractiveElement, IntoElement, MouseButton, + ParentElement, Pixels, Render, Styled, Subscription, Window, WindowBackgroundAppearance, + WindowBounds, WindowHandle, WindowKind, WindowOptions, canvas, div, point, popup, px, size, }; #[cfg(target_os = "macos")] @@ -31,8 +24,8 @@ use self::windows as platform; #[cfg(target_os = "linux")] use self::x11 as platform; use self::{ - faces::Face, - look::{Mood, PETTED, face, mood}, + look::{Act, EASE, Mood, Pose, blend, mood, pose}, + rig::Rig, }; use crate::{settings::SettingsStore, workspace::Workspace}; @@ -43,20 +36,22 @@ const ROOM: f32 = 16.0; /// How far in from the pane's left edge she holds its top edge. const LEFT: f32 = 140.0; -/// Ely in her window: her face follows the sessions' marks, and a press pets her. +/// Ely in her window: she moves all the time, her acts follow the sessions' marks, and a press pets her. pub struct Pet { - faces: Vec<(Face, Arc)>, + rig: Rc, mood: Mood, - /// When the mood began. + /// When the mood began, when she came, and when she was last pressed. since: Instant, + shown: Instant, petted: Option, - /// Counts her moves, so each plays from its start. - moves: usize, + /// What she was doing, her last pose, and the pose a change of act eases her from, with its time. + act: Act, + last: Option, + eased: Option<(Pose, Instant)>, /// How far in from the pane's left edge she opened. left: f32, /// What keeps her window on the main window's edge, where the platform does not. glue: Option, - _wake: Option>, _watch: Vec, } @@ -177,19 +172,19 @@ fn show(main: WindowHandle, cx: &mut App) -> anyhow::Result bool { } impl Pet { - /// Takes up `mood`: a new face, and the move it starts with. + /// Takes up `mood`, whose act begins now. fn feel(&mut self, mood: Mood, cx: &mut Context) { log::debug!("pet: Ely feels {mood:?}, was {:?}", self.mood); (self.mood, self.since) = (mood, Instant::now()); - self.moves += 1; - self.wake(cx); cx.notify(); } - /// Her face now, a press's wink first. - fn face(&self, now: Instant) -> (Face, Option) { - match self.petted.map(|at| now.saturating_duration_since(at)) { - Some(held) if held < PETTED => (Face::Petted, Some(PETTED - held)), - _ => face(self.mood, now.saturating_duration_since(self.since)), + /// Her pose now, eased from her last one while a change of act is fresh. + fn pose(&mut self, now: Instant) -> Pose { + let since = |at: Instant| now.saturating_duration_since(at).as_secs_f64(); + let mut pose = pose( + self.mood, + since(self.since), + since(self.shown), + self.petted.map(since), + ); + if pose.act != self.act { + log::debug!("pet: Ely goes from {:?} to {:?}", self.act, pose.act); + self.act = pose.act; + self.eased = self.last.take().map(|last| (last, now)); } - } - - /// Draws her again when her face changes next. - fn wake(&mut self, cx: &mut Context) { - let (_, next) = self.face(Instant::now()); - self._wake = next.map(|next| { - cx.spawn(async move |pet, cx| { - cx.background_executor().timer(next).await; - if pet - .update(cx, |pet, cx| { - pet.wake(cx); - cx.notify(); - }) - .is_err() - { - log::debug!("pet: her window went before her next face"); - } - }) - }); + if let Some((from, at)) = &self.eased { + let into = since(*at) as f32; + if into < EASE { + pose = blend(from, pose, into); + } else { + self.eased = None; + } + } + self.last = Some(pose.clone()); + pose } fn place(&self, main: &Window) { @@ -308,41 +300,23 @@ impl Pet { } impl Render for Pet { - fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { - let (face, _) = self.face(Instant::now()); - let image = (self.faces.iter()) - .find(|(drawn, _)| *drawn == face) - .map(|(_, image)| image.clone()) - .expect("every face is drawn"); - let (mood, first) = (self.mood, self.moves == 0); - let head = img(image) - .absolute() - .left_0() - .bottom_0() - .w(px(WIDTH)) - .h(px(HEAD)) - .with_animation( - ("ely", self.moves), - Animation::new(Duration::from_millis(600)), - move |head, delta| match mood { - // She rises from behind the edge when she comes. - _ if first => head.bottom(px(-HEAD * (1.0 - delta).powi(3))), - // A hop: two for a program that waits, one for one that finished. - Mood::Waiting | Mood::Done => { - let hops = if mood == Mood::Waiting { 2.0 } else { 1.0 }; - head.bottom(px((delta * hops * PI).sin().abs() * (ROOM - 4.0))) - } - // A shake that dies away. - Mood::Error => head.left(px((delta * 6.0 * PI).sin() * 4.0 * (1.0 - delta))), - Mood::Calm | Mood::Working => head, - }, - ); - div().size_full().relative().child(head).on_mouse_down( + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let pose = self.pose(Instant::now()); + // gpui gives a hidden window no frames, so she rests while unseen. + window.request_animation_frame(); + let rig = self.rig.clone(); + let drawn = canvas( + |_, _, _| {}, + move |bounds, _, window, cx| { + let head = point(bounds.origin.x, bounds.origin.y + px(ROOM)); + rig.paint(head, &pose, window, cx); + }, + ); + div().size_full().child(drawn.size_full()).on_mouse_down( MouseButton::Left, cx.listener(|pet, _, _, cx| { log::debug!("pet: Ely is pressed"); pet.petted = Some(Instant::now()); - pet.wake(cx); cx.notify(); }), ) diff --git a/src/pet/faces.rs b/src/pet/faces.rs deleted file mode 100644 index 6b1f17e..0000000 --- a/src/pet/faces.rs +++ /dev/null @@ -1,182 +0,0 @@ -//! Ely's faces: the icon's head, cut at the edge her fingers hold, one SVG a face. - -use std::sync::Arc; - -use gpui::{Image, ImageFormat}; - -/// What Ely's face shows. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] -pub enum Face { - Calm, - Blink, - /// Eyes on the terminal below her, as while you type or a program works. - Down, - Left, - Right, - /// A program finished: eyes shut in a smile, and sparkles. - Happy, - /// A program waits on you: wide eyes, an open mouth, and a mark. - Surprised, - /// A program failed: raised brows and a drop. - Worried, - Sleepy, - /// Pressed: a wink and a heart. - Petted, -} - -impl Face { - pub const ALL: [Face; 10] = [ - Face::Calm, - Face::Blink, - Face::Down, - Face::Left, - Face::Right, - Face::Happy, - Face::Surprised, - Face::Worried, - Face::Sleepy, - Face::Petted, - ]; -} - -/// The head's box in the icon's units; the edge she holds runs along its bottom. -const VIEW: [u32; 4] = [110, 6, 292, 280]; - -/// Pixels a unit draws at: three, so a Retina screen draws her sharp. -const SCALE: u32 = 3; - -/// Every face as an image gpui draws once and keeps. -pub fn images() -> Vec<(Face, Arc)> { - Face::ALL - .into_iter() - .map(|face| { - let svg = svg(face).into_bytes(); - (face, Arc::new(Image::from_bytes(ImageFormat::Svg, svg))) - }) - .collect() -} - -/// The SVG of `face`. -pub fn svg(face: Face) -> String { - let (eyes, mouth, mark) = match face { - Face::Calm => (open((0, 0), 1.0), SMILE, ""), - Face::Blink => (SHUT.into(), SMILE, ""), - Face::Down => (open((0, 5), 1.0), FLAT, ""), - Face::Left => (open((-5, 0), 1.0), SMILE, ""), - Face::Right => (open((5, 0), 1.0), SMILE, ""), - Face::Happy => (ARCS.into(), GRIN, SPARKLES), - Face::Surprised => (open((0, 0), 1.12), GASP, BANG), - Face::Worried => (open((0, 3), 1.0), FROWN, DROP), - Face::Sleepy => (LINES.into(), FLAT, SNORE), - Face::Petted => (wink(), GRIN, HEART), - }; - let blush = if face == Face::Petted { FLUSH } else { BLUSH }; - let brows = if face == Face::Worried { BROWS } else { "" }; - let [x, y, w, h] = VIEW; - let (width, height) = (w * SCALE, h * SCALE); - format!( - r#"{HEAD}{eyes}{blush}{mouth}{HAIR}{brows}{HANDS}{mark}"# - ) -} - -/// An eye at `cx`, its iris moved by `look`, the whole eye grown by `wide`. -fn eye(cx: i32, (dx, dy): (i32, i32), wide: f32) -> String { - format!( - r##""##, - -cx, - cx - 7, - cx + 7 - ) -} - -/// Both eyes open, with their lids and lashes. -fn open(look: (i32, i32), wide: f32) -> String { - format!( - "{}{}{LEFT_LID}{RIGHT_LID}{LASHES}", - eye(222, look, wide), - eye(290, look, wide) - ) -} - -/// The left eye shut in a smile, the right one open. -fn wink() -> String { - format!("{}{RIGHT_LID}{LEFT_ARC}", eye(290, (0, 0), 1.0)) -} - -const LEFT_LID: &str = r##""##; -const RIGHT_LID: &str = r##""##; -const LASHES: &str = r##""##; -const LEFT_ARC: &str = r##""##; -const SHUT: &str = r##""##; -const ARCS: &str = r##""##; -const LINES: &str = r##""##; -const BROWS: &str = r##""##; -const SMILE: &str = r##""##; -const GRIN: &str = r##""##; -const GASP: &str = r##""##; -const FLAT: &str = r##""##; -const FROWN: &str = r##""##; -const BLUSH: &str = r##""##; -const FLUSH: &str = r##""##; -const BANG: &str = r##""##; -const SNORE: &str = r##""##; -const HEART: &str = r##""##; -const SPARKLES: &str = r##""##; -const DROP: &str = r##""##; -/// Her hair behind, her collar, and her face: the icon's paths. -const HEAD: &str = concat!( - r##""##, - r##""##, - r##""##, - r##""##, - r##""##, - r##""##, - r##""##, - r##""##, - r##""##, -); - -/// Her side locks, bangs, ahoge and the star in her hair: the icon's paths. -const HAIR: &str = concat!( - r##""##, - r##""##, - r##""##, - r##""##, - r##""##, -); - -/// Her fingers over the edge she holds. -const HANDS: &str = concat!( - r##""##, - r##""##, -); - -#[cfg(test)] -mod tests { - use resvg::usvg; - - use super::*; - - #[test] - fn every_face_draws_at_three_times_her_box() { - for face in Face::ALL { - let svg = svg(face); - let tree = usvg::Tree::from_str(&svg, &usvg::Options::default()) - .unwrap_or_else(|error| panic!("{face:?}: {error}")); - assert_eq!(tree.size().width(), 292.0 * 3.0, "{face:?}"); - assert_eq!(tree.size().height(), 280.0 * 3.0, "{face:?}"); - } - assert_eq!(images().len(), Face::ALL.len()); - } - - #[test] - fn each_face_shows_what_it_says() { - assert!(svg(Face::Surprised).contains(BANG) && svg(Face::Surprised).contains(GASP)); - assert!(svg(Face::Worried).contains(BROWS) && svg(Face::Worried).contains(DROP)); - assert!(svg(Face::Happy).contains(ARCS) && svg(Face::Happy).contains(SPARKLES)); - assert!(svg(Face::Sleepy).contains(LINES) && svg(Face::Sleepy).contains(SNORE)); - assert!(svg(Face::Petted).contains(HEART) && svg(Face::Petted).contains(FLUSH)); - assert!(svg(Face::Blink).contains(SHUT) && !svg(Face::Blink).contains(LEFT_LID)); - assert_ne!(svg(Face::Left), svg(Face::Right)); - } -} diff --git a/src/pet/look.rs b/src/pet/look.rs index abacb06..301da5b 100644 --- a/src/pet/look.rs +++ b/src/pet/look.rs @@ -1,8 +1,15 @@ -//! What Ely feels from the sessions' marks, and the face she makes at each moment of it. +//! What Ely feels from the sessions' marks, and her pose at each moment of it, moving as the films move her. -use std::time::Duration; +mod curves; +#[cfg(test)] +mod tests; -use super::faces::Face; +use std::f32::consts::PI; + +use self::curves::{ + SHUT, between, blink, early, floating, fold, hops, opening, overshoot, popping, shaking, + smooth, tap, twinkling, wiggle, +}; use crate::sessions::orb::Look; /// What the sessions ask of her, least pressing first. @@ -30,120 +37,294 @@ pub fn mood(looks: impl IntoIterator) -> Mood { .unwrap_or(Mood::Calm) } -/// How long she cheers a program that finished, and how long calm lasts before she dozes. -const CHEER: Duration = Duration::from_secs(4); -const DOZE: Duration = Duration::from_secs(300); -/// How long a press keeps her wink. -pub const PETTED: Duration = Duration::from_millis(1500); +/// Seconds she cheers a program that finished, and calm lasts before she dozes. +const CHEER: f64 = 4.0; +const DOZE: f64 = 300.0; +/// Seconds a press keeps her wink. +const PETTED: f32 = 1.5; +/// Seconds she takes to rise from behind the edge, from as deep as she is tall. +const RISE: f32 = 0.6; +const DEPTH: f32 = 280.0; +/// Seconds she takes to ease from one act into the next. +pub const EASE: f32 = 0.3; -/// A calm round of 20 s: she blinks twice and glances left, then right. -const CALM: (f32, [(f32, Face); 9]) = ( - 20.0, - [ - (0.0, Face::Calm), - (4.0, Face::Blink), - (4.15, Face::Calm), - (9.0, Face::Left), - (10.2, Face::Calm), - (13.0, Face::Blink), - (13.15, Face::Calm), - (16.0, Face::Right), - (17.2, Face::Calm), - ], -); +/// What she is doing; a change eases her from her last pose. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Act { + Calm, + Doze, + Cheer, + Work, + Wait, + Fret, + Wink, +} -/// A working round of 8 s: her eyes stay on the terminal but for a blink. -const WORKING: (f32, [(f32, Face); 3]) = ( - 8.0, - [(0.0, Face::Down), (5.0, Face::Blink), (5.15, Face::Down)], -); +/// Her eyes: open, smiling arcs, sleeping lines, or a wink of the left. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Eyes { + Open, + Arcs, + Lines, + Wink, +} -/// Her face `held` into `mood`, and how long it holds; none while it holds for good. -pub fn face(mood: Mood, held: Duration) -> (Face, Option) { - match mood { - Mood::Waiting => (Face::Surprised, None), - Mood::Error => (Face::Worried, None), - Mood::Done if held < CHEER => (Face::Happy, Some(CHEER - held)), - Mood::Working => round(WORKING.0, &WORKING.1, held), - Mood::Calm | Mood::Done if held >= DOZE => (Face::Sleepy, None), - Mood::Calm | Mood::Done => { - let (face, next) = round(CALM.0, &CALM.1, held); - (face, next.map(|next| next.min(DOZE - held))) - } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Mouth { + Smile, + Grin, + Gasp, + Flat, + Frown, +} + +/// A mark beside her head. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Mark { + Bang, + Z1, + Z2, + Heart, + SparkleLeft, + SparkleRight, + Drop, +} + +/// How a mark moves from its place: by `at` units, grown by `scale`, turned `turn` degrees, at `alpha`. +#[derive(Clone, Copy, Debug)] +pub struct Motion { + pub at: (f32, f32), + pub scale: f32, + pub turn: f32, + pub alpha: f32, +} + +/// Her pose at one moment, in the icon's units and degrees: lift and bob go up, marks and hands move as the icon's y does, down. +#[derive(Clone, Debug)] +pub struct Pose { + pub act: Act, + /// All of her up, and aside. + pub lift: f32, + pub shake: f32, + /// Her head up with her breath, and turned. + pub bob: f32, + pub tilt: f32, + pub sway: f32, + pub ahoge: f32, + pub look: (f32, f32), + /// Each eye's height, left then right, and both eyes' size. + pub open: [f32; 2], + pub wide: f32, + pub eyes: Eyes, + pub mouth: Mouth, + /// The mouth's size, which pops as it changes. + pub pop: f32, + pub brows: bool, + pub flush: f32, + /// Each hand pressed down, left then right. + pub hands: [f32; 2], + pub marks: Vec<(Mark, Motion)>, +} + +/// Her pose `held` seconds into `mood`, `shown` seconds after she came, and `petted` seconds after a press. +pub fn pose(mood: Mood, held: f64, shown: f64, petted: Option) -> Pose { + let mut pose = match mood { + Mood::Waiting => waiting(held), + Mood::Error => fretting(held), + Mood::Working => working(held), + Mood::Done if held < CHEER => cheering(held), + Mood::Calm | Mood::Done if held >= DOZE => dozing(held - DOZE), + Mood::Calm => calm(held), + Mood::Done => calm(held - CHEER), + }; + if shown < RISE as f64 { + pose.lift -= DEPTH * (1.0 - shown as f32 / RISE).powi(3); + } + match petted.map(early) { + Some(since) if since < PETTED => winking(&mut pose, since), + Some(since) => pose.open[0] = pose.open[0].min(opening(since - PETTED)), + None => {} + } + pose +} + +/// `to`, `since` seconds after a change of act, eased from `from`, her last pose before it: her place and her moves blend, the marks `to` lacks fade, and the shapes are `to`'s. +pub fn blend(from: &Pose, to: Pose, since: f32) -> Pose { + let k = smooth((since / EASE).clamp(0.0, 1.0)); + let mix = |a: f32, b: f32| a + (b - a) * k; + let fading: Vec<(Mark, Motion)> = (from.marks.iter()) + .filter(|(mark, _)| to.marks.iter().all(|(kept, _)| kept != mark)) + .map(|&(mark, motion)| { + let alpha = motion.alpha * (1.0 - k); + (mark, Motion { alpha, ..motion }) + }) + .collect(); + let mut pose = Pose { + lift: mix(from.lift, to.lift), + shake: mix(from.shake, to.shake), + bob: mix(from.bob, to.bob), + tilt: mix(from.tilt, to.tilt), + sway: mix(from.sway, to.sway), + ahoge: mix(from.ahoge, to.ahoge), + look: (mix(from.look.0, to.look.0), mix(from.look.1, to.look.1)), + wide: mix(from.wide, to.wide), + flush: mix(from.flush, to.flush), + hands: [ + mix(from.hands[0], to.hands[0]), + mix(from.hands[1], to.hands[1]), + ], + ..to + }; + pose.marks.extend(fading); + pose +} + +/// Life under every act, `t` seconds into it: a bob of `amp` units every `period` seconds, swaying locks, a breathing ahoge, and blinks, her eyes opening and her mouth popping as the act begins. +fn idle(act: Act, t: f64, period: f32, amp: f32) -> Pose { + let phase = 2.0 * PI * fold(t, period) / period; + Pose { + act, + lift: 0.0, + shake: 0.0, + bob: phase.sin() * amp, + tilt: 0.0, + sway: (PI * fold(t, 2.0 * period) / period + 1.1).sin() * 2.2, + ahoge: (phase + 0.6).sin() * 4.0, + look: (0.0, 0.0), + open: [blink(t).min(opening(early(t))); 2], + wide: 1.0, + eyes: Eyes::Open, + mouth: Mouth::Smile, + pop: popping(early(t)), + brows: false, + flush: 0.0, + hands: [0.0; 2], + marks: Vec::new(), } } -/// The face a round of `period` seconds shows `held` into it, and how long it holds. -fn round(period: f32, steps: &[(f32, Face)], held: Duration) -> (Face, Option) { - let at = held.as_secs_f32() % period; - let now = steps - .iter() - .rposition(|&(start, _)| start <= at) - .expect("a round starts at 0"); - let end = steps.get(now + 1).map_or(period, |&(start, _)| start); - (steps[now].1, Some(Duration::from_secs_f32(end - at))) +/// Calm: she glances left, then right, her head after her eyes, and her ahoge wiggles. +fn calm(t: f64) -> Pose { + let mut pose = idle(Act::Calm, t, 2.6, 4.0); + let at = fold(t, 20.0); + let look = 6.0 * (between(at, 16.0, 17.2) - between(at, 9.0, 10.2)); + pose.look = (look, 0.0); + pose.tilt = look * 0.5; + pose.ahoge += wiggle(at - 12.4); + pose } -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn the_most_pressing_mark_sets_her_mood() { - assert_eq!(mood([]), Mood::Calm); - assert_eq!(mood([Look::Idle, Look::Done]), Mood::Done); - assert_eq!( - mood([Look::Done, Look::Working(Some(40)), Look::Idle]), - Mood::Working - ); - assert_eq!(mood([Look::Working(None), Look::Error]), Mood::Error); - assert_eq!( - mood([Look::Error, Look::Waiting, Look::Done]), - Mood::Waiting - ); - } - - #[test] - fn calm_blinks_and_glances_then_dozes() { - let at = |secs: f32| face(Mood::Calm, Duration::from_secs_f32(secs)).0; - assert_eq!(at(1.0), Face::Calm); - assert_eq!(at(4.1), Face::Blink); - assert_eq!(at(9.5), Face::Left); - assert_eq!(at(16.5), Face::Right); - assert_eq!(at(24.1), Face::Blink, "the round comes again"); - assert_eq!(at(300.0), Face::Sleepy); - assert_eq!( - face(Mood::Calm, Duration::from_secs(400)).1, - None, - "she sleeps on" - ); - let (_, wait) = face(Mood::Calm, Duration::from_secs_f32(1.0)); - assert!((wait.expect("a next face").as_secs_f32() - 3.0).abs() < 1e-3); - } - - #[test] - fn a_finished_program_cheers_her_then_she_calms() { - let (face_now, wait) = face(Mood::Done, Duration::from_secs(1)); - assert_eq!(face_now, Face::Happy); - assert_eq!(wait, Some(Duration::from_secs(3))); - assert_eq!(face(Mood::Done, Duration::from_secs(5)).0, Face::Calm); - } - - #[test] - fn waiting_and_errors_hold_their_faces() { - assert_eq!( - face(Mood::Waiting, Duration::from_secs(999)), - (Face::Surprised, None) - ); - assert_eq!(face(Mood::Error, Duration::ZERO), (Face::Worried, None)); - assert_eq!( - face(Mood::Working, Duration::from_secs_f32(5.1)).0, - Face::Blink - ); - assert_eq!( - face(Mood::Working, Duration::from_secs(1000)).0, - Face::Down, - "work never dozes" - ); +/// Dozing: her eyes fall shut, her head and ahoge droop, and zs float up. +fn dozing(t: f64) -> Pose { + let mut pose = idle(Act::Doze, t, 4.2, 3.0); + let settle = smooth((early(t) / 0.8).min(1.0)); + if t < 0.6 { + pose.open = [1.0 - (1.0 - SHUT) * smooth(early(t) / 0.6); 2]; + } else { + pose.eyes = Eyes::Lines; + pose.open = [1.0; 2]; } + pose.mouth = Mouth::Flat; + pose.tilt = settle * 5.0 + (2.0 * PI * fold(t, 8.4) / 8.4).sin() * 1.5; + pose.ahoge += settle * 14.0; + pose.marks = vec![(Mark::Z1, floating(t, 0.0)), (Mark::Z2, floating(t, 0.5))]; + pose +} + +/// Cheering: smiling eyes and mouth, a hop, and twinkling sparkles. +fn cheering(t: f64) -> Pose { + let mut pose = idle(Act::Cheer, t, 2.2, 4.0); + let t = early(t); + pose.eyes = Eyes::Arcs; + pose.mouth = Mouth::Grin; + pose.flush = 0.6; + pose.lift = hops(t, 1.0); + pose.ahoge += wiggle(t - 0.1); + pose.marks = vec![ + (Mark::SparkleLeft, twinkling(t, 0.0)), + (Mark::SparkleRight, twinkling(t, 0.45)), + ]; + pose +} + +/// Working: her eyes run along the terminal's lines, and her fingers tap by turns. +fn working(t: f64) -> Pose { + let mut pose = idle(Act::Work, t, 2.0, 2.5); + let at = fold(t, 2.25); + let scan = if at < 2.0 { + -5.0 + 10.0 * smooth(at / 2.0) + } else { + 5.0 - 10.0 * smooth((at - 2.0) / 0.25) + }; + pose.look = (scan, 5.0); + pose.mouth = Mouth::Flat; + pose.tilt = -2.0; + let beat = fold(t, 3.0); + if beat < 1.8 { + let n = (beat / 0.3) as usize; + pose.hands[n % 2] = tap(beat - n as f32 * 0.3); + } + pose +} + +/// Waiting on you: wide eyes, a gasp, a mark that rocks, and two hops every 2.6 s. +fn waiting(t: f64) -> Pose { + let mut pose = idle(Act::Wait, t, 1.6, 3.0); + let beat = 2.0 * PI * fold(t, 0.8) / 0.8; + pose.wide = 1.0 + 0.12 * overshoot((early(t) / 0.12).min(1.0)) + 0.02 * beat.sin(); + pose.mouth = Mouth::Gasp; + pose.look = (0.0, -2.0); + let at = fold(t, 2.6); + pose.lift = hops(at, 2.0); + pose.ahoge += wiggle(at); + let bang = Motion { + at: (0.0, 0.0), + scale: popping(early(t)), + turn: beat.sin() * 8.0, + alpha: 1.0, + }; + pose.marks = vec![(Mark::Bang, bang)]; + pose +} + +/// An error: brows up, a frown, a shake, a smaller one every 4.5 s, and a drop that runs down. +fn fretting(t: f64) -> Pose { + let mut pose = idle(Act::Fret, t, 2.4, 1.5); + pose.brows = true; + pose.mouth = Mouth::Frown; + pose.look = (0.0, 3.0); + pose.tilt = -3.0; + pose.shake = shaking(early(t), 10.0) + shaking(fold(t, 4.5) - 3.9, 4.0); + let f = fold(t, 2.0) / 2.0; + let drop = Motion { + at: (0.0, 18.0 * smooth(f)), + scale: 1.0, + turn: 0.0, + alpha: (f * PI).sin(), + }; + pose.marks = vec![(Mark::Drop, drop)]; + pose +} + +/// A press, `since` seconds ago: a wink, a grin, a blush, a small hop, a wiggle and a heart that rises; an eye she slept with opens. +fn winking(pose: &mut Pose, since: f32) { + let woke = pose.eyes != Eyes::Open; + pose.act = Act::Wink; + pose.eyes = Eyes::Wink; + pose.open = [ + opening(since), + if woke { opening(since) } else { pose.open[1] }, + ]; + pose.mouth = Mouth::Grin; + pose.pop = popping(since); + pose.flush = 1.0; + pose.lift += hops(since, 1.0) * 0.6; + pose.ahoge += wiggle(since); + let heart = Motion { + at: (0.0, -26.0 * since / PETTED), + scale: popping(since), + turn: 0.0, + alpha: ((PETTED - since) / 0.4).min(1.0), + }; + pose.marks.push((Mark::Heart, heart)); } diff --git a/src/pet/look/curves.rs b/src/pet/look/curves.rs new file mode 100644 index 0000000..6a6cc73 --- /dev/null +++ b/src/pet/look/curves.rs @@ -0,0 +1,140 @@ +//! The timings and eases Ely's moves take, after the films' tweens. + +use std::f32::consts::PI; + +use super::Motion; + +/// How far her eyes close in a blink. +pub(super) const SHUT: f32 = 0.08; +/// A hop's height: 12 points. +const HOP: f32 = 31.0; +/// Seconds between blinks, a round she repeats; the short gap blinks twice. +const BLINKS: [f32; 6] = [3.4, 4.6, 0.32, 5.2, 2.8, 4.1]; + +/// How open an eye is `t` seconds into the blink round: shut in 70 ms, open again in 120 ms. +pub(super) fn blink(t: f64) -> f32 { + let round: f32 = BLINKS.iter().sum(); + let at = fold(t, round); + // A round ends in a blink; the first has none before it. + let mut last = if t < round as f64 { + f32::NEG_INFINITY + } else { + 0.0 + }; + let mut time = 0.0; + for gap in BLINKS { + time += gap; + if time <= at { + last = time; + } + } + match at - last { + s if s < 0.07 => 1.0 - (1.0 - SHUT) * (s / 0.07).powi(2), + s if s < 0.1 => SHUT, + s if s < 0.22 => SHUT + (1.0 - SHUT) * (1.0 - (1.0 - (s - 0.1) / 0.12).powi(2)), + _ => 1.0, + } +} + +/// A shape that opens from a line in 150 ms, `t` seconds after it came. +pub(super) fn opening(t: f32) -> f32 { + SHUT + (1.0 - SHUT) * smooth((t / 0.15).clamp(0.0, 1.0)) +} + +/// A mouth or mark that pops in over 180 ms, `t` seconds after it came. +pub(super) fn popping(t: f32) -> f32 { + 0.6 + 0.4 * overshoot((t / 0.18).clamp(0.0, 1.0)) +} + +/// `count` hops in 0.6 s, `since` they began. +pub(super) fn hops(since: f32, count: f32) -> f32 { + if !(0.0..0.6).contains(&since) { + return 0.0; + } + (since / 0.6 * count * PI).sin().abs() * HOP +} + +/// A shake of `amp` units that dies away in 0.6 s, `since` it began. +pub(super) fn shaking(since: f32, amp: f32) -> f32 { + if !(0.0..0.6).contains(&since) { + return 0.0; + } + let u = since / 0.6; + (u * 6.0 * PI).sin() * amp * (1.0 - u) +} + +/// The ahoge's wiggle, `since` it began: -16°, 11°, -6°, then still, in 0.7 s. +pub(super) fn wiggle(since: f32) -> f32 { + const KEYS: [(f32, f32); 5] = [ + (0.0, 0.0), + (0.12, -16.0), + (0.28, 11.0), + (0.46, -6.0), + (0.7, 0.0), + ]; + let Some(i) = (KEYS.windows(2)).position(|pair| (pair[0].0..pair[1].0).contains(&since)) else { + return 0.0; + }; + let ((t0, v0), (t1, v1)) = (KEYS[i], KEYS[i + 1]); + v0 + (v1 - v0) * smooth((since - t0) / (t1 - t0)) +} + +/// A finger's tap, `since` it began: down 4 units in 60 ms, up in 160 ms. +pub(super) fn tap(since: f32) -> f32 { + if since < 0.06 { + 4.0 * (since / 0.06).powi(2) + } else { + 4.0 * (1.0 - smooth(((since - 0.06) / 0.16).min(1.0))) + } +} + +/// A z that floats up from its place and fades every 2.4 s, `phase` of a round late. +pub(super) fn floating(t: f64, phase: f32) -> Motion { + let f = (fold(t, 2.4) / 2.4 + phase) % 1.0; + Motion { + at: (f * 6.0, -f * 22.0), + scale: 0.8 + 0.4 * f, + turn: 0.0, + alpha: (f * PI).sin() * (early(t) / 0.8).min(1.0), + } +} + +/// A sparkle that grows in and twinkles, `phase` of a 0.9 s round late. +pub(super) fn twinkling(t: f32, phase: f32) -> Motion { + let pulse = (2.0 * PI * (t / 0.9 + phase)).sin(); + Motion { + at: (0.0, 0.0), + scale: overshoot((t / 0.25).min(1.0)) * (0.85 + 0.25 * pulse), + turn: pulse * 12.0, + alpha: 1.0, + } +} + +/// `t` folded into a round of `period` seconds, which keeps its steps fine after weeks. +pub(super) fn fold(t: f64, period: f32) -> f32 { + (t % period as f64) as f32 +} + +/// `t` while it is short; past a minute, what follows a start has long ended. +pub(super) fn early(t: f64) -> f32 { + t.min(60.0) as f32 +} + +/// 1 inside `from..to`, 0 outside, eased in and out over 0.35 s. +pub(super) fn between(at: f32, from: f32, to: f32) -> f32 { + smooth( + ((at - from) / 0.35) + .clamp(0.0, 1.0) + .min(((to - at) / 0.35).clamp(0.0, 1.0)), + ) +} + +pub(super) fn smooth(x: f32) -> f32 { + x * x * (3.0 - 2.0 * x) +} + +/// An ease that runs past its end and settles back, as GSAP's back.out. +pub(super) fn overshoot(x: f32) -> f32 { + const C: f32 = 1.70158; + 1.0 + (C + 1.0) * (x - 1.0).powi(3) + C * (x - 1.0).powi(2) +} diff --git a/src/pet/look/tests.rs b/src/pet/look/tests.rs new file mode 100644 index 0000000..ef0ab9c --- /dev/null +++ b/src/pet/look/tests.rs @@ -0,0 +1,203 @@ +use super::*; + +#[test] +fn the_most_pressing_mark_sets_her_mood() { + assert_eq!(mood([]), Mood::Calm); + assert_eq!(mood([Look::Idle, Look::Done]), Mood::Done); + assert_eq!( + mood([Look::Done, Look::Working(Some(40)), Look::Idle]), + Mood::Working + ); + assert_eq!(mood([Look::Working(None), Look::Error]), Mood::Error); + assert_eq!( + mood([Look::Error, Look::Waiting, Look::Done]), + Mood::Waiting + ); +} + +/// Her pose `held` seconds into `mood`, long after she came, unpressed. +fn at(mood: Mood, held: f64) -> Pose { + pose(mood, held, 10.0, None) +} + +#[test] +fn she_moves_in_every_mood_even_after_weeks() { + let weeks = 3.0 * 7.0 * 86_400.0; + for (mood, held) in [ + (Mood::Calm, 30.0), + (Mood::Calm, 400.0), + (Mood::Done, 50.0), + (Mood::Working, 30.0), + (Mood::Waiting, 30.0), + (Mood::Error, 30.0), + (Mood::Working, weeks), + (Mood::Calm, weeks), + ] { + let (now, soon) = (at(mood, held), at(mood, held + 0.3)); + assert_ne!(now.bob, soon.bob, "{mood:?} at {held}: her breath"); + assert_ne!(now.ahoge, soon.ahoge, "{mood:?} at {held}: her ahoge"); + assert_ne!(now.sway, soon.sway, "{mood:?} at {held}: her locks"); + } +} + +#[test] +fn she_blinks_on_her_own_twice_at_the_short_gap() { + let open = |held: f64| at(Mood::Calm, held).open; + assert_eq!(open(2.0), [1.0, 1.0], "open between blinks"); + assert!(open(3.48)[0] < 0.1, "shut in the blink at 3.4 s"); + assert_eq!(open(3.7), [1.0, 1.0], "open again"); + assert!( + open(8.08)[0] < 0.1 && open(8.4)[0] < 0.1, + "twice at the short gap" + ); + assert!( + open(3.43)[0] > open(3.46)[0], + "she shuts them, not snaps them" + ); + assert!( + open(0.0)[0] < 0.1 && open(0.2) == [1.0, 1.0], + "her eyes open as an act begins" + ); +} + +#[test] +fn calm_glances_left_then_right_with_her_head() { + let calm = |held: f64| at(Mood::Calm, held); + assert_eq!(calm(5.0).look, (0.0, 0.0)); + assert_eq!(calm(9.6).look, (-6.0, 0.0)); + assert_eq!(calm(16.6).look, (6.0, 0.0)); + assert!( + calm(16.6).tilt > 0.0 && calm(9.6).tilt < 0.0, + "her head follows" + ); + assert!(calm(9.1).look.0 > -6.0, "her eyes ease over"); + let breath = idle(Act::Calm, 12.6, 2.6, 4.0).ahoge; + assert!( + (calm(12.6).ahoge - breath).abs() > 1.0, + "her ahoge wiggles after her glances" + ); +} + +#[test] +fn calm_dozes_after_five_minutes_with_zs() { + let dozing = at(Mood::Calm, 301.0); + assert_eq!((dozing.act, dozing.eyes), (Act::Doze, Eyes::Lines)); + let marks: Vec = dozing.marks.iter().map(|(mark, _)| *mark).collect(); + assert_eq!(marks, [Mark::Z1, Mark::Z2]); + assert!( + at(Mood::Calm, 300.3).open[0] < 1.0, + "her eyes fall shut first" + ); + let z = |held: f64| at(Mood::Calm, held).marks[0].1; + assert!(z(305.6).at.1 < z(305.0).at.1, "the zs float up"); +} + +#[test] +fn a_finish_cheers_her_with_a_hop_then_she_calms() { + let cheer = at(Mood::Done, 0.3); + assert_eq!( + (cheer.act, cheer.eyes, cheer.mouth), + (Act::Cheer, Eyes::Arcs, Mouth::Grin) + ); + assert!(cheer.lift > 10.0, "a hop"); + assert_eq!(cheer.marks.len(), 2, "two sparkles"); + let calm = at(Mood::Done, 5.0); + assert_eq!( + (calm.act, calm.eyes, calm.mouth), + (Act::Calm, Eyes::Open, Mouth::Smile) + ); + assert!( + at(Mood::Done, 4.0).open[0] < 0.1, + "her eyes open again as she calms" + ); +} + +#[test] +fn work_runs_her_eyes_along_the_lines_and_taps_her_fingers() { + let work = |held: f64| at(Mood::Working, held); + assert_eq!(work(1.0).act, Act::Work); + assert_eq!(work(1.0).look.1, 5.0, "her eyes on the terminal"); + assert!(work(0.4).look.0 < work(1.6).look.0, "along a line"); + assert!( + work(0.03).hands[0] > 0.0 && work(0.33).hands[1] > 0.0, + "by turns" + ); + assert_eq!(work(2.5).hands, [0.0, 0.0], "then a rest"); +} + +#[test] +fn a_wait_widens_her_eyes_and_hops_her_twice_every_round() { + let wait = |held: f64| at(Mood::Waiting, held); + assert!(wait(1.0).wide > 1.1); + assert_eq!((wait(1.0).act, wait(1.0).mouth), (Act::Wait, Mouth::Gasp)); + assert!(wait(0.15).lift > 20.0 && wait(0.45).lift > 20.0, "two hops"); + assert!(wait(2.75).lift > 20.0, "again next round"); + assert_eq!(wait(1.5).lift, 0.0); + assert_eq!(wait(1.0).marks[0].0, Mark::Bang); +} + +#[test] +fn an_error_frets_her_with_a_shake_and_a_drop() { + let fret = |held: f64| at(Mood::Error, held); + assert!(fret(1.0).brows); + assert_eq!((fret(1.0).act, fret(1.0).mouth), (Act::Fret, Mouth::Frown)); + assert!(fret(0.15).shake.abs() > 1.0, "a shake"); + assert_eq!(fret(1.0).shake, 0.0); + assert!(fret(4.05).shake.abs() > 1.0, "a smaller one later"); + assert!( + fret(1.5).marks[0].1.at.1 > fret(0.5).marks[0].1.at.1, + "the drop runs down" + ); +} + +#[test] +fn a_press_winks_with_a_heart_that_rises() { + let pressed = |since: f64| pose(Mood::Calm, 30.0, 30.0, Some(since)); + assert_eq!( + (pressed(0.5).act, pressed(0.5).eyes), + (Act::Wink, Eyes::Wink) + ); + assert_eq!(pressed(0.5).marks[0].0, Mark::Heart); + assert!(pressed(1.0).marks[0].1.at.1 < pressed(0.5).marks[0].1.at.1); + assert_eq!( + (pressed(1.6).act, pressed(1.6).eyes), + (Act::Calm, Eyes::Open), + "the wink lasts 1.5 s" + ); + assert!(pressed(1.55).open[0] < 0.5, "her eye opens after it"); + let asleep = pose(Mood::Calm, 400.0, 400.0, Some(0.5)); + assert!(asleep.open[1] > 0.9, "a press wakes her other eye"); +} + +#[test] +fn she_rises_from_behind_the_edge_when_she_comes() { + assert!(pose(Mood::Calm, 0.0, 0.0, None).lift <= -DEPTH); + assert!(pose(Mood::Calm, 0.3, 0.3, None).lift < 0.0); + assert_eq!(pose(Mood::Calm, 5.0, 0.6, None).lift, 0.0); +} + +#[test] +fn a_change_of_act_eases_her_from_her_last_pose() { + let working = at(Mood::Working, 1.0); + let early = blend(&working, at(Mood::Calm, 0.1), 0.1); + assert!( + early.tilt < 0.0 && early.tilt > working.tilt, + "her head turns back, not at once: {} from {}", + early.tilt, + working.tilt + ); + let calm = at(Mood::Calm, EASE as f64); + let eased = blend(&working, calm.clone(), EASE); + assert!( + (eased.tilt - calm.tilt).abs() < 1e-5 && (eased.look.1 - calm.look.1).abs() < 1e-5, + "eased in {EASE} s" + ); + let waiting = at(Mood::Waiting, 1.0); + let leaving = blend(&waiting, at(Mood::Calm, 0.1), 0.1); + let bang = (leaving.marks.iter()) + .find(|(mark, _)| *mark == Mark::Bang) + .expect("the mark she leaves fades, not vanishes") + .1; + assert!(bang.alpha > 0.0 && bang.alpha < 1.0); + assert_eq!(leaving.eyes, Eyes::Open, "the shapes are the new act's"); +} diff --git a/src/pet/rig.rs b/src/pet/rig.rs new file mode 100644 index 0000000..e7fbc63 --- /dev/null +++ b/src/pet/rig.rs @@ -0,0 +1,304 @@ +//! Ely rigged as the films rig her: the icon's paths in layers of one color, each a mask gpui draws with a matrix of its own. + +use gpui::{ + App, Bounds, Hsla, Pixels, Point, ScaledPixels, SharedString, TransformationMatrix, Window, + point, px, radians, rgb, size, +}; + +use super::{ + WIDTH, + look::{Eyes, Mark, Mouth, Pose}, +}; + +/// A point or a shift in the window's device pixels. +type Device = (f32, f32); + +/// The head's box in the icon's units; the edge she holds runs along its bottom. +const VIEW: [f32; 4] = [110.0, 6.0, 292.0, 280.0]; +/// Points a unit draws at. +const UNIT: f32 = WIDTH / VIEW[2]; + +/// How a layer moves. +#[derive(Clone, Copy, PartialEq)] +enum Bone { + /// Her neck and collar, which stay on the edge as her head bobs. + Body, + /// Bobs, and turns with her head. + Head, + /// An open eye's lid and lash, which a blink squashes; left is 0. + Lid(usize), + /// An open eye, which looks about as well. + Iris(usize), + Arc(usize), + Lines, + Brows, + Blush, + Mouth(Mouth), + Lock(usize), + Ahoge, + /// Holds the edge; taps. + Hand(usize), + Mark(Mark), +} + +/// A layer: its name, color and opacity, its box in the icon's units, the point it turns and grows about, how it moves, and its shapes. +struct Part { + name: &'static str, + color: u32, + alpha: f32, + frame: [f32; 4], + pivot: (f32, f32), + bone: Bone, + shapes: &'static str, +} + +/// A part with no pivot of its own. +#[rustfmt::skip] +const fn part(name: &'static str, color: u32, frame: [f32; 4], bone: Bone, shapes: &'static str) -> Part { + Part { name, color, alpha: 1.0, frame, pivot: (256.0, 236.0), bone, shapes } +} + +/// Every layer, back to front. +#[rustfmt::skip] +const PARTS: &[Part] = &[ + part("hair-back", 0xEC86B5, [117.0, 45.0, 278.0, 262.0], Bone::Head, r#""#), + part("shade-back", 0xD9659A, [193.0, 199.0, 126.0, 78.0], Bone::Head, r#""#), + part("neck", 0xF7CDBE, [235.0, 221.0, 42.0, 50.0], Bone::Body, r#""#), + part("collar", 0xFFFFFF, [197.0, 255.0, 118.0, 30.0], Bone::Body, r#""#), + part("bow", 0xF05A97, [221.0, 247.0, 70.0, 32.0], Bone::Body, r#""#), + part("knot", 0xD94683, [247.0, 255.0, 18.0, 18.0], Bone::Body, r#""#), + part("skin", 0xFFE7DC, [177.0, 93.0, 158.0, 152.0], Bone::Head, concat!(r#""#)), + part("face-shade", 0xF9D8CB, [161.0, 73.0, 190.0, 118.0], Bone::Head, concat!(r#""#)), + eye("iris-l", 0xA24FB9, 222.0, 0, r#""#), + eye("ring-l", 0xEE84C4, 222.0, 0, r#""#), + eye("pupil-l", 0x5A2368, 222.0, 0, r#""#), + eye("shine-l", 0xFFFFFF, 222.0, 0, r#""#), + Part { name: "lid-l", color: 0x45233F, alpha: 1.0, frame: [193.0, 158.0, 52.0, 26.0], pivot: (222.0, 192.0), bone: Bone::Lid(0), shapes: r#""# }, + Part { name: "lash-l", color: 0xC2506F, alpha: 0.35, frame: [213.0, 210.0, 18.0, 6.0], pivot: (222.0, 192.0), bone: Bone::Lid(0), shapes: r#""# }, + eye("iris-r", 0xA24FB9, 290.0, 1, r#""#), + eye("ring-r", 0xEE84C4, 290.0, 1, r#""#), + eye("pupil-r", 0x5A2368, 290.0, 1, r#""#), + eye("shine-r", 0xFFFFFF, 290.0, 1, r#""#), + Part { name: "lid-r", color: 0x45233F, alpha: 1.0, frame: [267.0, 158.0, 52.0, 26.0], pivot: (290.0, 192.0), bone: Bone::Lid(1), shapes: r#""# }, + Part { name: "lash-r", color: 0xC2506F, alpha: 0.35, frame: [281.0, 210.0, 18.0, 6.0], pivot: (290.0, 192.0), bone: Bone::Lid(1), shapes: r#""# }, + Part { name: "arc-l", color: 0x45233F, alpha: 1.0, frame: [200.0, 171.0, 44.0, 31.0], pivot: (222.0, 192.0), bone: Bone::Arc(0), shapes: r#""# }, + Part { name: "arc-r", color: 0x45233F, alpha: 1.0, frame: [268.0, 171.0, 44.0, 31.0], pivot: (290.0, 192.0), bone: Bone::Arc(1), shapes: r#""# }, + part("lines", 0x45233F, [201.0, 190.0, 110.0, 8.0], Bone::Lines, r#""#), + Part { name: "blush", color: 0xFF8DAA, alpha: 0.55, frame: [180.0, 206.0, 152.0, 20.0], pivot: (256.0, 216.0), bone: Bone::Blush, shapes: r#""# }, + mouth("smile", 0xC2506F, [241.0, 219.0, 30.0, 13.0], Mouth::Smile, r#""#), + mouth("grin", 0xC2506F, [242.0, 218.0, 28.0, 24.0], Mouth::Grin, r#""#), + mouth("tongue", 0xFF8DAA, [248.0, 224.0, 16.0, 13.0], Mouth::Grin, r#""#), + mouth("gasp", 0xC2506F, [249.0, 218.0, 14.0, 16.0], Mouth::Gasp, r#""#), + mouth("flat", 0xC2506F, [246.0, 222.0, 20.0, 6.0], Mouth::Flat, r#""#), + mouth("frown", 0xC2506F, [243.0, 217.0, 26.0, 14.0], Mouth::Frown, r#""#), + Part { name: "lock-l", color: 0xF7A6C9, alpha: 1.0, frame: [147.0, 117.0, 54.0, 200.0], pivot: (184.0, 118.0), bone: Bone::Lock(0), shapes: r#""# }, + Part { name: "lock-r", color: 0xF7A6C9, alpha: 1.0, frame: [311.0, 117.0, 54.0, 200.0], pivot: (328.0, 118.0), bone: Bone::Lock(1), shapes: r#""# }, + part("bangs", 0xF7A6C9, [161.0, 61.0, 190.0, 118.0], Bone::Head, concat!(r#""#)), + part("gloss", 0xFFD1E4, [195.0, 83.0, 122.0, 26.0], Bone::Head, r#""#), + Part { name: "ahoge", color: 0xF7A6C9, alpha: 1.0, frame: [237.0, 17.0, 44.0, 50.0], pivot: (258.0, 64.0), bone: Bone::Ahoge, shapes: r#""# }, + part("pin", 0xFFE48A, [303.0, 85.0, 30.0, 30.0], Bone::Head, r#""#), + part("brows", 0x45233F, [202.0, 148.0, 108.0, 16.0], Bone::Brows, r#""#), + part("hand-l", 0xFFE7DC, [149.0, 261.0, 46.0, 26.0], Bone::Hand(0), r#""#), + part("knuckles-l", 0xF2BBA8, [159.0, 271.0, 26.0, 17.0], Bone::Hand(0), r#""#), + part("hand-r", 0xFFE7DC, [317.0, 261.0, 46.0, 26.0], Bone::Hand(1), r#""#), + part("knuckles-r", 0xF2BBA8, [327.0, 271.0, 26.0, 17.0], Bone::Hand(1), r#""#), + mark("bang", 0xFFB020, [368.0, 35.0, 20.0, 75.0], (378.0, 107.0), Mark::Bang, r#""#), + mark("bang-edge", 0x45233F, [368.0, 35.0, 20.0, 75.0], (378.0, 107.0), Mark::Bang, r#""#), + mark("z1", 0x9FA3D9, [348.0, 66.0, 28.0, 30.0], (362.0, 81.0), Mark::Z1, r#""#), + mark("z2", 0x9FA3D9, [376.0, 34.0, 22.0, 24.0], (387.0, 46.0), Mark::Z2, r#""#), + mark("heart", 0xFF5C93, [349.0, 55.0, 58.0, 46.0], (378.0, 82.0), Mark::Heart, r#""#), + mark("sparkle-l", 0xFFE48A, [123.0, 69.0, 34.0, 34.0], (140.0, 86.0), Mark::SparkleLeft, r#""#), + mark("sparkle-r", 0xFFE48A, [367.0, 51.0, 26.0, 26.0], (380.0, 64.0), Mark::SparkleRight, r#""#), + mark("drop", 0x9CD5FF, [336.0, 126.0, 22.0, 32.0], (346.0, 142.0), Mark::Drop, r#""#), + mark("drop-edge", 0x5AA0D8, [336.0, 126.0, 22.0, 32.0], (346.0, 142.0), Mark::Drop, r#""#), +]; + +/// A layer of an open eye centred at `x`. +#[rustfmt::skip] +const fn eye(name: &'static str, color: u32, x: f32, side: usize, shapes: &'static str) -> Part { + let frame = [x - 19.0, 164.0, 38.0, 48.0]; + Part { name, color, alpha: 1.0, frame, pivot: (x, 192.0), bone: Bone::Iris(side), shapes } +} + +#[rustfmt::skip] +const fn mouth(name: &'static str, color: u32, frame: [f32; 4], mouth: Mouth, shapes: &'static str) -> Part { + Part { name, color, alpha: 1.0, frame, pivot: (256.0, 224.0), bone: Bone::Mouth(mouth), shapes } +} + +#[rustfmt::skip] +const fn mark(name: &'static str, color: u32, frame: [f32; 4], pivot: (f32, f32), mark: Mark, shapes: &'static str) -> Part { + Part { name, color, alpha: 1.0, frame, pivot, bone: Bone::Mark(mark), shapes } +} + +/// Every layer's key and SVG, made once. +pub struct Rig { + layers: Vec<(&'static Part, SharedString, Vec)>, +} + +impl Rig { + pub fn new() -> Self { + let layers = (PARTS.iter()) + .map(|part| { + let key = format!("ely/{}.svg", part.name).into(); + (part, key, svg(part).into_bytes()) + }) + .collect(); + Self { layers } + } + + /// Draws her in `pose`, the head's box from `origin`. + pub fn paint(&self, origin: Point, pose: &Pose, window: &mut Window, cx: &App) { + let scale = window.scale_factor(); + let (left, top) = (f32::from(origin.x), f32::from(origin.y)); + let at = |(x, y): (f32, f32)| { + let x = (left + (x - VIEW[0]) * UNIT) * scale; + (x, (top + (y - VIEW[1]) * UNIT) * scale) + }; + let by = |(x, y): (f32, f32)| (x * UNIT * scale, y * UNIT * scale); + let whole = shift(TransformationMatrix::unit(), by((pose.shake, -pose.lift))); + let breath = shift(whole, by((0.0, -pose.bob))); + let head = turn(breath, at((256.0, 236.0)), pose.tilt); + for (part, key, svg) in &self.layers { + let Some((matrix, alpha)) = moved(part, pose, [whole, head], &at, &by) else { + continue; + }; + let [x, y, w, h] = part.frame; + let bounds = Bounds::new( + origin + point(px((x - VIEW[0]) * UNIT), px((y - VIEW[1]) * UNIT)), + size(px(w * UNIT), px(h * UNIT)), + ); + let color = Hsla::from(rgb(part.color)).opacity(part.alpha * alpha); + let drawn = + window.paint_svg(bounds, key.clone(), Some(svg.as_slice()), matrix, color, cx); + if let Err(error) = drawn { + log::error!("pet: Ely's {} does not draw: {error:#}", part.name); + } + } + } +} + +/// Where `part` stands in `pose`, and its opacity; none while the pose hides it. +fn moved( + part: &Part, + pose: &Pose, + [whole, head]: [TransformationMatrix; 2], + at: &impl Fn((f32, f32)) -> Device, + by: &impl Fn((f32, f32)) -> Device, +) -> Option<(TransformationMatrix, f32)> { + let pivot = at(part.pivot); + let open = |side: usize| pose.eyes == Eyes::Open || (pose.eyes == Eyes::Wink && side == 1); + let shut = |side: usize| pose.eyes == Eyes::Arcs || (pose.eyes == Eyes::Wink && side == 0); + let squash = |side: usize, wide: f32| grow(head, pivot, (wide, wide * pose.open[side])); + Some(match part.bone { + Bone::Body => (whole, 1.0), + Bone::Head => (head, 1.0), + Bone::Lid(side) if open(side) => (squash(side, pose.wide), 1.0), + Bone::Iris(side) if open(side) => (shift(squash(side, pose.wide), by(pose.look)), 1.0), + Bone::Arc(side) if shut(side) => (squash(side, 1.0), 1.0), + Bone::Lines if pose.eyes == Eyes::Lines => (head, 1.0), + Bone::Brows if pose.brows => (head, 1.0), + Bone::Blush => (head, 1.0 + 0.6 * pose.flush), + Bone::Mouth(mouth) if mouth == pose.mouth => (grow(head, pivot, (pose.pop, pose.pop)), 1.0), + Bone::Lock(side) => { + let sway = if side == 0 { pose.sway } else { -pose.sway }; + (turn(head, pivot, sway), 1.0) + } + Bone::Ahoge => (turn(head, pivot, pose.ahoge), 1.0), + Bone::Hand(side) => (shift(whole, by((0.0, pose.hands[side]))), 1.0), + Bone::Mark(mark) => { + let &(_, motion) = pose.marks.iter().find(|(shown, _)| *shown == mark)?; + let moved = shift(head, by(motion.at)); + let turned = turn(moved, pivot, motion.turn); + ( + grow(turned, pivot, (motion.scale, motion.scale)), + motion.alpha, + ) + } + _ => return None, + }) +} + +fn shift(matrix: TransformationMatrix, (x, y): Device) -> TransformationMatrix { + matrix.translate(point(ScaledPixels(x), ScaledPixels(y))) +} + +/// `matrix`, after a turn of `degrees` clockwise about `pivot`. +fn turn(matrix: TransformationMatrix, pivot: Device, degrees: f32) -> TransformationMatrix { + let turned = shift(matrix, pivot).rotate(radians(degrees.to_radians())); + shift(turned, (-pivot.0, -pivot.1)) +} + +/// `matrix`, after a growth by `(x, y)` about `pivot`. +fn grow(matrix: TransformationMatrix, pivot: Device, (x, y): (f32, f32)) -> TransformationMatrix { + shift(shift(matrix, pivot).scale(size(x, y)), (-pivot.0, -pivot.1)) +} + +/// The SVG of `part`: its shapes in its box. +fn svg(part: &Part) -> String { + let [x, y, w, h] = part.frame; + format!( + r#"{}"#, + part.shapes + ) +} + +#[cfg(test)] +mod tests { + use std::collections::HashSet; + + use resvg::usvg; + + use super::*; + + #[test] + fn every_layer_parses_and_stays_in_its_box() { + let mut names = HashSet::new(); + for part in PARTS { + assert!(names.insert(part.name), "{} twice", part.name); + let tree = usvg::Tree::from_str(&svg(part), &usvg::Options::default()) + .unwrap_or_else(|error| panic!("{}: {error}", part.name)); + let drawn = tree.root().abs_stroke_bounding_box(); + let [_, _, width, height] = part.frame; + assert!( + drawn.left() >= 0.0 + && drawn.top() >= 0.0 + && drawn.right() <= width + && drawn.bottom() <= height, + "{}: {drawn:?} spills out of {width} by {height}", + part.name + ); + } + } + + #[test] + fn she_is_drawn_from_every_part_the_icon_gives_her() { + let named = |name: &str| PARTS.iter().any(|part| part.name == name); + for name in [ + "hair-back", + "skin", + "bangs", + "ahoge", + "lock-l", + "lock-r", + "hand-l", + "hand-r", + ] { + assert!(named(name), "{name}"); + } + let marks = [ + Mark::Bang, + Mark::Z1, + Mark::Z2, + Mark::Heart, + Mark::SparkleLeft, + Mark::SparkleRight, + Mark::Drop, + ]; + for mark in marks { + assert!( + PARTS.iter().any(|part| part.bone == Bone::Mark(mark)), + "{mark:?}" + ); + } + } +} diff --git a/tasks/acceptance.md b/tasks/acceptance.md index 59e309a..46da3f3 100644 --- a/tasks/acceptance.md +++ b/tasks/acceptance.md @@ -84,7 +84,7 @@ What no check reached: no real KWin drew the blur; of Wayland desktops, only Hyp | The pointer over the terminal is the arrow | T26 | the terminal sets no pointer, so gpui's arrow shows; on X11 with the pointer over the grid, gpui asked for no I-beam ("text"), which it had asked for, and failed to load under Xvfb, before | Pass | | The title bar is transparent | T11, T19 | T19 review | Pass | | Every title bar control takes its press, Ely's window buttons on Windows and Linux too | T26 | Ely `cdc5ce8`: `window_controls_occlude_what_lies_under` (each style; before, the box under Windows' caption buttons took the press), `a_wobbling_press_on_a_slot_moves_no_window` (before, the window moved), `a_wobbling_press_on_a_column_title_moves_the_window`; on the owner's PC, window messages asked the app for Windows' hit test along the title bar: v0.1.0 answered caption from end to end, T26's build answers client over the toggles and +, caption over the titles, min, max and close over Ely's buttons, and a press on close closed the app; the owner, given v0.1.1's build and these controls to click, reported it works on Windows | Pass | -| Ely peeks over the window's top edge from behind it; her face follows the sessions; a press pets her | T46, T48 | `pet::look` tests: the most pressing mark sets her mood, calm blinks, glances and dozes, a finish cheers for 4 s, waiting and errors hold; `pet::faces` tests: every face parses at three times her box and shows its marks; `pet::x11` test under Xvfb: her window stands on the pane, just above the window manager's frame, and unmaps and maps with the main window; on macOS `shot` found her the main window's child, 112 by 124 points on its top edge 140 in, a capture of the screen showed her over the edge, and OSC 7501 reports gave her surprise, worry, eyes down and a cheer; a press on her window left the main window key and every key typed before and after it reached the program; switched off and on during an error she came back worried; on a 100-point window she stood at its left edge, and moved, resized and minimized she followed it; on Windows the `pet::windows` test, red before T48, finds the framed popup Windows makes, 1898 by 1481, bare in her own box, 168 by 185 at 150%, on the pane's top edge, and no size until her window takes the place posted to it; `pet::windows` tests, red before T48's second round: with the main window at 1, 1.5 or 2 and hers at 1, 1.25 or 1.5, her height is her own and her bottom the pane's top, where it fell 62 pixels into the terminal, and at a new DPI gpui takes the rect Windows suggests, then her box comes back on her corner, or Windows' rect stands where that box would lie on a display of another DPI; two displays side by side at 144 and 96 DPI, simulated, settle her after one change, where a corner kept always, red before T48's third round, changed her DPI back and forth; and two places piled up before either ran drain on the last corner in her DPI's size, where a keep whose DPI had passed, red before T48's fourth change, set it back and forth, and where a place sized when it was posted left her 112 by 123 at 144 DPI, red before its fifth; on a Windows 11 VM at 150%, with the main window 49 pixels from the screen's top, 0.1.4 opened her 2326 by 1654 with a caption, drawn at its bottom over the terminal, and a press inside the terminal went to her; T48's build opened her 168 by 185 without one, on the edge, the press went to the terminal, one beside her box to the program behind, and one on her to her; with the window mid-screen she showed whole on its edge; Wayland's popup is gpui's own | Pass | +| Ely peeks over the window's top edge from behind it; her face follows the sessions; a press pets her | T46, T48, T49 | `pet::look` tests: the most pressing mark sets her mood; she moves in every mood, weeks into one too; she blinks on her own, twice at times, her eyes opening as an act begins; calm glances, then dozes with zs; a finish cheers her with a hop; work runs her eyes along the lines and taps her fingers; a wait widens her eyes and hops her twice a round; an error shakes her and runs a drop down; a press winks with a rising heart; she rises from behind the edge; a change of act eases her from her last pose, the marks she leaves fading; `pet::rig` tests: every layer of the icon's paths parses and stays in its box, and every mark has its layer; on macOS `shot`'s snaps, 40 in 10 s while calm, caught her breathing, blinking and glancing, and two a second apart in each mood caught her reading along, rocking her mark, fretting with her drop, cheering, and easing back to calm; `pet::x11` test under Xvfb: her window stands on the pane, just above the window manager's frame, and unmaps and maps with the main window; on macOS `shot` found her the main window's child, 112 by 124 points on its top edge 140 in, a capture of the screen showed her over the edge, and OSC 7501 reports gave her surprise, worry, eyes down and a cheer; a press on her window left the main window key and every key typed before and after it reached the program; switched off and on during an error she came back worried; on a 100-point window she stood at its left edge, and moved, resized and minimized she followed it; on Windows the `pet::windows` test, red before T48, finds the framed popup Windows makes, 1898 by 1481, bare in her own box, 168 by 185 at 150%, on the pane's top edge, and no size until her window takes the place posted to it; `pet::windows` tests, red before T48's second round: with the main window at 1, 1.5 or 2 and hers at 1, 1.25 or 1.5, her height is her own and her bottom the pane's top, where it fell 62 pixels into the terminal, and at a new DPI gpui takes the rect Windows suggests, then her box comes back on her corner, or Windows' rect stands where that box would lie on a display of another DPI; two displays side by side at 144 and 96 DPI, simulated, settle her after one change, where a corner kept always, red before T48's third round, changed her DPI back and forth; and two places piled up before either ran drain on the last corner in her DPI's size, where a keep whose DPI had passed, red before T48's fourth change, set it back and forth, and where a place sized when it was posted left her 112 by 123 at 144 DPI, red before its fifth; on a Windows 11 VM at 150%, with the main window 49 pixels from the screen's top, 0.1.4 opened her 2326 by 1654 with a caption, drawn at its bottom over the terminal, and a press inside the terminal went to her; T48's build opened her 168 by 185 without one, on the edge, the press went to the terminal, one beside her box to the program behind, and one on her to her; with the window mid-screen she showed whole on its edge; Wayland's popup is gpui's own | Pass | ## 7. Sessions panel