From 577240840227707d49c595a48eb7e0ae63d221ee Mon Sep 17 00:00:00 2001 From: Chris <76012699+ChrisPlayer@users.noreply.github.com> Date: Sun, 18 Oct 2026 14:21:41 +0200 Subject: [PATCH 1/5] feat(island): six island places in the prefs, with their geometry --- src/lib/companion/prefs.ts | 9 ++++++ src/lib/island/place.test.ts | 43 ++++++++++++++++++++++++++ src/lib/island/place.ts | 60 ++++++++++++++++++++++++++++++++++++ 3 files changed, 112 insertions(+) create mode 100644 src/lib/island/place.test.ts create mode 100644 src/lib/island/place.ts diff --git a/src/lib/companion/prefs.ts b/src/lib/companion/prefs.ts index 00a290a..98cddf0 100644 --- a/src/lib/companion/prefs.ts +++ b/src/lib/companion/prefs.ts @@ -23,6 +23,11 @@ export type CompanionRobots = 'island' | 'desktop'; /** What the island's pill shows when nothing else claims it: the live line, the quota left (`headroom`), the music, the running timer, or the robot alone. */ export type PillRest = 'live' | 'quota' | 'music' | 'timer' | 'robot'; export const PILL_RESTS = ['live', 'quota', 'music', 'timer', 'robot'] as const satisfies readonly PillRest[]; +/** Where the island sits: against the top or the bottom edge of the screen (the bottom one above the taskbar), on its left, its centre or its right. */ +export type IslandPlace = `${IslandEdge}-${IslandSide}`; +export type IslandEdge = 'top' | 'bottom'; +export type IslandSide = 'left' | 'center' | 'right'; +export const ISLAND_PLACES = ['top-left', 'top-center', 'top-right', 'bottom-left', 'bottom-center', 'bottom-right'] as const satisfies readonly IslandPlace[]; /** The character's centre, in fractions of the screen's work area. */ export interface CompanionSpot { x: number; y: number } /** The families the island's mascot may come from: every robot Boite draws, and the box. */ @@ -41,6 +46,8 @@ export interface CompanionPrefs { openOnHover: boolean; /** What the pill shows at rest (`IslandBar.svelte`). */ pillRest: PillRest; + /** Where the island sits on its screen; dragging the pill moves it too. */ + islandPlace: IslandPlace; /** The island's character, the first agent's on the desktop; null is that agent's own robot (Boîte, the classic box, by default). */ mascot: Mascot | null; anchor: CompanionAnchor; @@ -93,6 +100,7 @@ export const DEFAULT_COMPANION_PREFS: CompanionPrefs = { robots: 'island', openOnHover: true, pillRest: 'live', + islandPlace: 'top-center', mascot: null, anchor: 'center', spot: null, @@ -158,6 +166,7 @@ export function parseCompanionPrefs(raw: unknown): CompanionPrefs { robots: value.robots === 'desktop' ? 'desktop' : 'island', openOnHover: value.openOnHover !== false, pillRest: (PILL_RESTS as readonly unknown[]).includes(value.pillRest) ? (value.pillRest as PillRest) : 'live', + islandPlace: (ISLAND_PLACES as readonly unknown[]).includes(value.islandPlace) ? (value.islandPlace as IslandPlace) : 'top-center', mascot: mascotOf(value.mascot), // A free place needs the spot it was dropped on. anchor: value.anchor === 'left' || value.anchor === 'right' || (value.anchor === 'free' && spot) ? value.anchor : 'center', diff --git a/src/lib/island/place.test.ts b/src/lib/island/place.test.ts new file mode 100644 index 0000000..59ba2f9 --- /dev/null +++ b/src/lib/island/place.test.ts @@ -0,0 +1,43 @@ +import { expect, test } from 'vitest'; +import { DEFAULT_COMPANION_PREFS, ISLAND_PLACES, parseCompanionPrefs } from '../companion/prefs'; +import { nearestPlace, parsePlace, PLACE_MARGIN, placeLayout, placeParts, placeRect } from './place'; + +const screen = { width: 1600, height: 900 }; +const pill = { width: 200, height: 32 }; + +test('saved prefs from before the places keep the island at the top centre', () => { + expect(DEFAULT_COMPANION_PREFS.islandPlace).toBe('top-center'); + expect(parseCompanionPrefs({ robots: 'island', pillRest: 'quota' }).islandPlace).toBe('top-center'); + expect(parseCompanionPrefs({ islandPlace: 'middle' }).islandPlace).toBe('top-center'); + expect(parseCompanionPrefs({ islandPlace: 7 }).islandPlace).toBe('top-center'); + for (const place of ISLAND_PLACES) expect(parseCompanionPrefs({ islandPlace: place }).islandPlace).toBe(place); +}); + +test('a place splits into its edge and side, as the shell takes them', () => { + expect(placeParts('bottom-right')).toEqual({ edge: 'bottom', side: 'right' }); + expect(placeLayout('top-left')).toEqual({ align: 'left', edge: 'top' }); + expect(placeLayout('bottom-center')).toEqual({ align: 'center', edge: 'bottom' }); + expect(parsePlace('bottom-left')).toBe('bottom-left'); + expect(parsePlace('left')).toBeNull(); + expect(parsePlace(null)).toBeNull(); +}); + +test('the places sit against their edge, the sides a margin in', () => { + expect(placeRect('top-left', screen, pill)).toEqual({ x: PLACE_MARGIN, y: 0, w: 200, h: 32 }); + expect(placeRect('top-center', screen, pill)).toEqual({ x: 700, y: 0, w: 200, h: 32 }); + expect(placeRect('bottom-right', screen, pill)).toEqual({ x: 1600 - 200 - PLACE_MARGIN, y: 868, w: 200, h: 32 }); + // An island wider than the screen never goes off it. + expect(placeRect('top-right', { width: 800, height: 600 }, { width: 860, height: 470 })).toMatchObject({ x: 0, y: 0 }); + expect(placeRect('bottom-left', { width: 870, height: 400 }, { width: 860, height: 470 })).toMatchObject({ x: 10, y: 0 }); +}); + +test('a dragged island lands on the nearest place', () => { + expect(nearestPlace({ x: 800, y: 16 }, screen, pill)).toBe('top-center'); + expect(nearestPlace({ x: 120, y: 300 }, screen, pill)).toBe('top-left'); + expect(nearestPlace({ x: 120, y: 600 }, screen, pill)).toBe('bottom-left'); + expect(nearestPlace({ x: 1590, y: 890 }, screen, pill)).toBe('bottom-right'); + expect(nearestPlace({ x: 1050, y: 800 }, screen, pill)).toBe('bottom-center'); + expect(nearestPlace({ x: 1300, y: 100 }, screen, pill)).toBe('top-right'); + // Off the screen, it still lands on one. + expect(nearestPlace({ x: -400, y: 2000 }, screen, pill)).toBe('bottom-left'); +}); diff --git a/src/lib/island/place.ts b/src/lib/island/place.ts new file mode 100644 index 0000000..a8a7e8c --- /dev/null +++ b/src/lib/island/place.ts @@ -0,0 +1,60 @@ +/* + * The island's six places: the top or the bottom edge of its screen, at the + * left, the centre or the right (`prefs.islandPlace`). The shell puts the + * window flush with that edge and side (`island.rs`); the page keeps the + * island `PLACE_MARGIN` from a side, so its shadow has room. + * + * While the pill is dragged, the window spans the screen and the page draws + * the six places as targets (`placeRect`); the island goes to the one nearest + * where it was let go (`nearestPlace`). + */ +import { ISLAND_PLACES, type IslandEdge, type IslandPlace, type IslandSide } from '../companion/prefs'; +import type { CompanionLayout } from '../companion/shell'; + +/** The island's distance from the screen's side at the left and right places, CSS pixels. */ +export const PLACE_MARGIN = 16; + +export interface PlaceParts { edge: IslandEdge; side: IslandSide } +export interface Rect { x: number; y: number; w: number; h: number } +export interface Size { width: number; height: number } + +export function placeParts(place: IslandPlace): PlaceParts { + const [edge, side] = place.split('-') as [IslandEdge, IslandSide]; + return { edge, side }; +} + +export function placeOf(edge: IslandEdge, side: IslandSide): IslandPlace { + return `${edge}-${side}`; +} + +/** A place as the pose and the demo name it; anything else is none. */ +export function parsePlace(value: string | null | undefined): IslandPlace | null { + return (ISLAND_PLACES as readonly unknown[]).includes(value) ? (value as IslandPlace) : null; +} + +/** The place as the shell takes it (`Layout`): the side the window is aligned on, the edge it sits against. */ +export function placeLayout(place: IslandPlace): CompanionLayout { + const { edge, side } = placeParts(place); + return { align: side, edge }; +} + +/** Where an island of `size` sits at `place` on a screen of `screen`, CSS pixels. */ +export function placeRect(place: IslandPlace, screen: Size, size: Size, margin = PLACE_MARGIN): Rect { + const { edge, side } = placeParts(place); + const free = Math.max(0, screen.width - size.width); + const x = side === 'left' ? Math.min(margin, free) : side === 'right' ? Math.max(0, free - margin) : free / 2; + const y = edge === 'top' ? 0 : Math.max(0, screen.height - size.height); + return { x, y, w: size.width, h: size.height }; +} + +/** The place whose island's centre is nearest `centre`: where a dragged island lands. */ +export function nearestPlace(centre: { x: number; y: number }, screen: Size, size: Size, margin = PLACE_MARGIN): IslandPlace { + let best: IslandPlace = 'top-center'; + let distance = Infinity; + for (const place of ISLAND_PLACES) { + const rect = placeRect(place, screen, size, margin); + const away = Math.hypot(rect.x + rect.w / 2 - centre.x, rect.y + rect.h / 2 - centre.y); + if (away < distance) [best, distance] = [place, away]; + } + return best; +} From 5f32be3de337afa98e4720ef4f54c113b43f9572 Mon Sep 17 00:00:00 2001 From: Chris <76012699+ChrisPlayer@users.noreply.github.com> Date: Sun, 18 Oct 2026 14:21:41 +0200 Subject: [PATCH 2/5] feat(island): place the window at any of six places and dock robots there --- src-tauri/permissions/bots.toml | 1 + src-tauri/src/acl.rs | 4 +- src-tauri/src/companion_window.rs | 32 +++-- src-tauri/src/island.rs | 189 +++++++++++++++++++++----- src-tauri/src/lib.rs | 1 + src/lib/companion/companion.svelte.ts | 5 +- src/lib/companion/shell.ts | 22 ++- 7 files changed, 199 insertions(+), 55 deletions(-) diff --git a/src-tauri/permissions/bots.toml b/src-tauri/permissions/bots.toml index 9de4658..276aa4a 100644 --- a/src-tauri/permissions/bots.toml +++ b/src-tauri/permissions/bots.toml @@ -6,6 +6,7 @@ commands.allow = [ "companion_monitors", "companion_place", "island_place", + "island_span", "companion_hit_rects", "companion_media", "companion_media_control", diff --git a/src-tauri/src/acl.rs b/src-tauri/src/acl.rs index 4e7bb6b..d69c6a0 100644 --- a/src-tauri/src/acl.rs +++ b/src-tauri/src/acl.rs @@ -4,9 +4,9 @@ mod tests { use tauri::ipc::Origin; - const COMPANION: [&str; 40] = ["core_endpoint", "companion_monitors", "companion_place", "companion_hit_rects", "companion_media", + const COMPANION: [&str; 41] = ["core_endpoint", "companion_monitors", "companion_place", "companion_hit_rects", "companion_media", "companion_media_control", "companion_media_levels", "companion_configure", "companion_drag", "companion_capture", "companion_cover", "companion_focus", - "companion_keep", "island_place", "bots_settings", "bots_tray_labels", "plugin:event|listen", "plugin:event|unlisten", + "companion_keep", "island_place", "island_span", "bots_settings", "bots_tray_labels", "plugin:event|listen", "plugin:event|unlisten", "computer_system", "computer_close", "computer_power", "computer_apps", "computer_app_icon", "computer_app_open", "computer_link_open", "computer_clipboard_watch", "computer_clipboard_list", "computer_clipboard_copy", "computer_clipboard_remove", "computer_clipboard_item", "computer_wallpapers", "computer_wallpaper_folder", "computer_wallpaper_pick", "computer_wallpaper_thumb", "computer_wallpaper_set", "computer_engine", diff --git a/src-tauri/src/companion_window.rs b/src-tauri/src/companion_window.rs index 86f8d40..dbb2f27 100644 --- a/src-tauri/src/companion_window.rs +++ b/src-tauri/src/companion_window.rs @@ -63,6 +63,8 @@ struct Shared { hidden: Arc, /// The screen the window covers while the user picks a part of it. covering: Arc>>, + /// The island is being dragged to another place: the window spans its screen. + spanning: Arc, } #[derive(Default)] @@ -81,14 +83,14 @@ pub struct CompanionState { pub enum Anchor { Left, #[default] Center, Right } /// The edge of the window the character sits on; the panel opens away from it. -#[derive(Serialize, Clone, Copy, Debug, PartialEq, Default)] +#[derive(Deserialize, Serialize, Clone, Copy, Debug, PartialEq, Default)] #[serde(rename_all = "lowercase")] pub enum Edge { #[default] Top, Bottom } /// How the page lays itself out in the window, so the character lands where -/// it was placed. -#[derive(Serialize, Clone, Copy, Debug, PartialEq, Default)] -pub struct Layout { align: Anchor, edge: Edge } +/// it was placed. The island's place is one too: its side and its edge. +#[derive(Deserialize, Serialize, Clone, Copy, Debug, PartialEq, Default)] +pub struct Layout { pub(crate) align: Anchor, pub(crate) edge: Edge } /// Where the user dropped the character: its centre, in fractions of the /// screen's work area. @@ -96,7 +98,7 @@ pub struct Layout { align: Anchor, edge: Edge } pub struct Spot { x: f64, y: f64 } /// The screen and the spot a drag ended on, for the page to keep, and -/// whether it ended against the top edge, back into the island (`island::docks`). +/// whether it ended near the island's edge, back into it (`island::docks`). #[derive(Serialize, Debug, PartialEq)] pub struct Dropped { monitor: String, spot: Spot, docked: bool } @@ -122,7 +124,7 @@ fn inside(rects: &[HitRect], x: f64, y: f64) -> bool { /// The window's top left on a work area, physical pixels: against the top /// edge, centered or `margin` from a side. -fn origin(area: Area, width: u32, anchor: Anchor, margin: i32) -> (i32, i32) { +pub(crate) fn origin(area: Area, width: u32, anchor: Anchor, margin: i32) -> (i32, i32) { let (left, top, area_width, _) = area; let free = area_width as i32 - width as i32; let x = match anchor { @@ -362,8 +364,8 @@ impl Senses { /// user is typing: input came while the pointer stayed still and no button /// is down. Which key never reaches the shell. fn pointer(&mut self, window: &WebviewWindow, shared: &Shared, point: (f64, f64), tick: u32) { - // A covering window takes every click: the user is picking a part of the screen. - let whole = shared.covering.lock().unwrap_or_else(PoisonError::into_inner).is_some(); + // A covering window takes every click: the user is picking a part of the screen, or carrying the island. + let whole = shared.spanning.load(Ordering::Acquire) || shared.covering.lock().unwrap_or_else(PoisonError::into_inner).is_some(); let over = whole || inside(&shared.rects.lock().unwrap_or_else(PoisonError::into_inner), point.0, point.1); let down = crate::platform::mouse_down(); let (press_away, outside) = outside_click(self.press_away, down, over); @@ -449,6 +451,7 @@ pub fn open(app: &AppHandle) -> tauri::Result<()> { let state = app.state::(); state.shared.rects.lock().unwrap_or_else(PoisonError::into_inner).clear(); *state.shared.covering.lock().unwrap_or_else(PoisonError::into_inner) = None; + state.shared.spanning.store(false, Ordering::Release); state.shared.hidden.store(false, Ordering::Release); let mine = state.shared.generation.fetch_add(1, Ordering::AcqRel) + 1; let window = build(app)?; @@ -464,6 +467,12 @@ pub fn open(app: &AppHandle) -> tauri::Result<()> { Ok(()) } +/// While the island is carried to another place, the window takes every +/// click, wherever the page's areas are. +pub(crate) fn take_every_click(app: &AppHandle, every: bool) { + app.state::().shared.spanning.store(every, Ordering::Release); +} + fn window_of(app: &AppHandle) -> Result { app.get_webview_window(LABEL).ok_or_else(|| "the companion window is closed".into()) } @@ -495,9 +504,10 @@ pub async fn companion_place(app: AppHandle, webview: Webview, monitor: Option) -> Result, String> { +pub async fn companion_drag(app: AppHandle, webview: Webview, detach: Option, place: Option) -> Result, String> { only_companion(&webview)?; let window = window_of(&app)?; let detach = detach.unwrap_or(false); @@ -522,7 +532,7 @@ pub async fn companion_drag(app: AppHandle, webview: Webview, detach: Option window.current_monitor().map_err(|error| error.to_string())?.ok_or("the companion is on no screen")?, }; let area = work_area(&monitor); - let docked = crate::island::docks(area, monitor.scale_factor(), centre); + let docked = crate::island::docks(area, monitor.scale_factor(), centre, place.unwrap_or_default()); Ok(Some(Dropped { monitor: monitor_name(&monitor), spot: spot_at(area, centre), docked })) } diff --git a/src-tauri/src/island.rs b/src-tauri/src/island.rs index 751c07b..f500b58 100644 --- a/src-tauri/src/island.rs +++ b/src-tauri/src/island.rs @@ -1,32 +1,42 @@ -//! The island: the companion's window hanging from the top edge of a screen, -//! centred, at the size the page asks for its state (collapsed, peek, open). +//! The island: the companion's window against the top or the bottom edge of a +//! screen, on its left, its centre or its right (`Layout`, the place the user +//! picked), at the size the page asks for its state (collapsed, peek, open). //! It is the same window as the desktop robots (`companion_window.rs`), so the //! click-through, the pointer watch, the capture and the shortcut serve both; //! the page says which of the two it draws by calling `island_place` or //! `companion_place`. //! +//! The window sits flush with the work area's edge and side: the page keeps +//! the island's margin from the side itself, so its shadow and halo have room. +//! //! The island's place follows the screen: the watch asks `follow` twice a //! second, and a work area that changed (a taskbar or top panel moved, a scale -//! changed, the screen unplugged) puts the window back at the top centre. +//! changed, the screen unplugged) puts the window back at its place. +//! +//! While the user drags the island to another place, the window spans the +//! whole work area and takes every click (`island_span`), so the page can draw +//! the places it may go to and carry the island over them. -use crate::companion_window::{only_companion, pick_monitor, work_area, Area}; +use crate::companion_window::{only_companion, origin, pick_monitor, work_area, Area, Edge, Layout}; use serde::Serialize; use std::sync::{Mutex, PoisonError}; use tauri::{AppHandle, Manager, PhysicalPosition, PhysicalSize, Runtime, Webview, WebviewWindow}; -/// How close to the top of a work area a robot dropped on the desktop must be, -/// in logical pixels from its centre, to go back into the island. +/// How close to the island's edge of a work area a robot dropped on the +/// desktop must be, in logical pixels from its centre, to go back into it. pub const DOCK_REACH: f64 = 72.0; -/// The island the page asked for: the screen, and the window's logical size. +/// The island the page asked for: the screen, its place, and the window's logical size. #[derive(Clone, Debug, PartialEq)] -struct Wanted { monitor: Option, width: f64, height: f64 } +struct Wanted { monitor: Option, place: Layout, width: f64, height: f64 } -/// What the island asked for last, and where that put it. +/// What the island asked for last, where that put it, and whether the window +/// spans the work area for a drag. #[derive(Default)] pub struct IslandState { wanted: Mutex>, placed: Mutex>, + spanning: Mutex, } /// A window's place, `(x, y, width, height)` in physical pixels. @@ -37,25 +47,41 @@ type Frame = (i32, i32, u32, u32); pub struct Placed { monitor: String, scale: f64 } /// The island's window on a work area: `size` logical pixels at `scale`, -/// never wider or taller than the area, centred and against its top edge. -pub fn island_frame(area: Area, scale: f64, size: (f64, f64)) -> Frame { +/// never wider or taller than the area, against the edge and on the side of `place`. +pub fn island_frame(area: Area, scale: f64, size: (f64, f64), place: Layout) -> Frame { let pixels = |value: f64, most: u32| ((value.max(1.0) * scale).round() as u32).min(most.max(1)); let (width, height) = (pixels(size.0, area.2), pixels(size.1, area.3)); - (area.0 + (area.2 - width) as i32 / 2, area.1, width, height) + let (x, top) = origin(area, width, place.align, 0); + let y = match place.edge { Edge::Top => top, Edge::Bottom => top + (area.3 - height) as i32 }; + (x, y, width, height) } /// A robot dropped with its centre at `centre`, physical pixels, goes back -/// into the island when it is within `DOCK_REACH` of the work area's top edge. -pub fn docks(area: Area, scale: f64, centre: (i32, i32)) -> bool { +/// into the island when it is within `DOCK_REACH` of the island's edge of the +/// work area, on the island's side: anywhere along the edge for a centred +/// island, the left or the right half for one on a side. +pub fn docks(area: Area, scale: f64, centre: (i32, i32), place: Layout) -> bool { let reach = (DOCK_REACH * scale).round() as i32; let (left, top, width, height) = area; - centre.0 >= left && centre.0 < left + width as i32 && centre.1 >= top - reach && centre.1 < top.saturating_add(height as i32).min(top + reach) + let (right, bottom) = (left.saturating_add(width as i32), top.saturating_add(height as i32)); + let middle = left + (width / 2) as i32; + let (from, to) = match place.align { + crate::companion_window::Anchor::Left => (left, middle), + crate::companion_window::Anchor::Center => (left, right), + crate::companion_window::Anchor::Right => (middle, right), + }; + let along = centre.0 >= from && centre.0 < to; + let near = match place.edge { + Edge::Top => centre.1 >= top - reach && centre.1 < bottom.min(top + reach), + Edge::Bottom => centre.1 < bottom + reach && centre.1 >= top.max(bottom - reach), + }; + along && near } fn frame_for(window: &WebviewWindow, wanted: &Wanted) -> tauri::Result> { let Some(monitor) = pick_monitor(window, wanted.monitor.as_deref())? else { return Ok(None) }; let scale = monitor.scale_factor(); - let frame = island_frame(work_area(&monitor), scale, (wanted.width, wanted.height)); + let frame = island_frame(work_area(&monitor), scale, (wanted.width, wanted.height), wanted.place); Ok(Some((frame, crate::companion_window::monitor_name(&monitor), scale))) } @@ -66,17 +92,26 @@ fn set_frame(window: &WebviewWindow, frame: Frame) -> tauri::Resu window.set_position(PhysicalPosition::new(frame.0, frame.1)) } +/// The drag over: the window takes clicks only over the page's areas again. +fn end_span(app: &AppHandle) { + *app.state::().spanning.lock().unwrap_or_else(PoisonError::into_inner) = false; + crate::companion_window::take_every_click(app, false); +} + /// The page draws the robots on the desktop again: the island stops following the screen. pub fn forget(app: &AppHandle) { + end_span(app); let state = app.state::(); *state.wanted.lock().unwrap_or_else(PoisonError::into_inner) = None; *state.placed.lock().unwrap_or_else(PoisonError::into_inner) = None; } -/// Called by the pointer watch: the island goes back to the top centre when -/// the screen under it changed. Nothing while the robots are on the desktop. +/// Called by the pointer watch: the island goes back to its place when the +/// screen under it changed. Nothing while the robots are on the desktop, or +/// while the island is being dragged. pub fn follow(app: &AppHandle, window: &WebviewWindow) { let state = app.state::(); + if *state.spanning.lock().unwrap_or_else(PoisonError::into_inner) { return; } let Some(wanted) = state.wanted.lock().unwrap_or_else(PoisonError::into_inner).clone() else { return }; let Ok(Some((frame, _, _))) = frame_for(window, &wanted) else { return }; let mut placed = state.placed.lock().unwrap_or_else(PoisonError::into_inner); @@ -88,14 +123,16 @@ pub fn follow(app: &AppHandle, window: &WebviewWindow) { *placed = Some(frame); } -/// Hangs the island from the top edge of the screen named `monitor` (the -/// primary one when null or gone), `width` × `height` logical pixels. +/// Puts the island at `place` (`top` or `bottom`, `left`, `center` or +/// `right`) of the screen named `monitor` (the primary one when null or +/// gone), `width` × `height` logical pixels. Ends a drag's span. #[tauri::command] -pub async fn island_place(app: AppHandle, webview: Webview, monitor: Option, width: f64, height: f64) -> Result, String> { +pub async fn island_place(app: AppHandle, webview: Webview, monitor: Option, place: Option, width: f64, height: f64) -> Result, String> { only_companion(&webview)?; if !(width.is_finite() && height.is_finite()) { return Err("the island's size must be finite".into()); } let window = app.get_webview_window(crate::companion_window::LABEL).ok_or("the companion window is closed")?; - let wanted = Wanted { monitor, width, height }; + let wanted = Wanted { monitor, place: place.unwrap_or_default(), width, height }; + end_span(&app); let state = app.state::(); *state.wanted.lock().unwrap_or_else(PoisonError::into_inner) = Some(wanted.clone()); let Some((frame, name, scale)) = frame_for(&window, &wanted).map_err(|error| error.to_string())? else { return Ok(None) }; @@ -104,36 +141,120 @@ pub async fn island_place(app: AppHandle, webview: Webview, monitor: Option Result<(), String> { + only_companion(&webview)?; + let window = app.get_webview_window(crate::companion_window::LABEL).ok_or("the companion window is closed")?; + let state = app.state::(); + let Some(wanted) = state.wanted.lock().unwrap_or_else(PoisonError::into_inner).clone() else { return Ok(()) }; + if !span { + end_span(&app); + let Some((frame, _, _)) = frame_for(&window, &wanted).map_err(|error| error.to_string())? else { return Ok(()) }; + set_frame(&window, frame).map_err(|error| error.to_string())?; + *state.placed.lock().unwrap_or_else(PoisonError::into_inner) = Some(frame); + return Ok(()); + } + let Some(monitor) = pick_monitor(&window, wanted.monitor.as_deref()).map_err(|error| error.to_string())? else { return Ok(()) }; + // Every click first, so the press carrying the island is not let through as the window grows. + crate::companion_window::take_every_click(&app, true); + *state.spanning.lock().unwrap_or_else(PoisonError::into_inner) = true; + let area = work_area(&monitor); + set_frame(&window, area).map_err(|error| error.to_string())?; + *state.placed.lock().unwrap_or_else(PoisonError::into_inner) = Some(area); + Ok(()) +} + #[cfg(test)] mod tests { use super::*; + use crate::companion_window::Anchor; + + const fn at(edge: Edge, align: Anchor) -> Layout { Layout { align, edge } } + const TOP: Layout = at(Edge::Top, Anchor::Center); #[test] fn the_island_hangs_centred_from_the_top_of_the_work_area() { // A 1080p screen, its taskbar at the bottom. - assert_eq!(island_frame((0, 0, 1920, 1040), 1.0, (680.0, 480.0)), (620, 0, 680, 480)); + assert_eq!(island_frame((0, 0, 1920, 1040), 1.0, (680.0, 480.0), TOP), (620, 0, 680, 480)); // Scaled 150 %: the logical size grows in pixels, still centred. - assert_eq!(island_frame((0, 0, 2560, 1400), 1.5, (680.0, 480.0)), (770, 0, 1020, 720)); + assert_eq!(island_frame((0, 0, 2560, 1400), 1.5, (680.0, 480.0), TOP), (770, 0, 1020, 720)); // A screen left of the primary one, under a taskbar at its top. - assert_eq!(island_frame((-2560, 48, 2560, 1392), 1.0, (360.0, 96.0)), (-1460, 48, 360, 96)); + assert_eq!(island_frame((-2560, 48, 2560, 1392), 1.0, (360.0, 96.0), TOP), (-1460, 48, 360, 96)); // A top panel on a screen above the primary one. - assert_eq!(island_frame((0, -1032, 1920, 1032), 1.25, (360.0, 96.0)), (735, -1032, 450, 120)); + assert_eq!(island_frame((0, -1032, 1920, 1032), 1.25, (360.0, 96.0), TOP), (735, -1032, 450, 120)); } #[test] fn the_island_never_outgrows_its_screen() { - assert_eq!(island_frame((0, 0, 800, 300), 2.0, (680.0, 480.0)), (0, 0, 800, 300)); - assert_eq!(island_frame((100, 0, 1000, 700), 1.0, (0.0, -5.0)), (599, 0, 1, 1)); + assert_eq!(island_frame((0, 0, 800, 300), 2.0, (680.0, 480.0), TOP), (0, 0, 800, 300)); + assert_eq!(island_frame((100, 0, 1000, 700), 1.0, (0.0, -5.0), TOP), (599, 0, 1, 1)); + assert_eq!(island_frame((0, 0, 800, 300), 2.0, (680.0, 480.0), at(Edge::Bottom, Anchor::Right)), (0, 0, 800, 300)); + } + + #[test] + fn the_island_sits_flush_with_the_edge_and_the_side_of_its_place() { + // A 1080p screen, its taskbar at the bottom: the bottom places sit just above it. + let area = (0, 0, 1920, 1040); + let size = (940.0, 150.0); + assert_eq!(island_frame(area, 1.0, size, at(Edge::Top, Anchor::Left)), (0, 0, 940, 150)); + assert_eq!(island_frame(area, 1.0, size, at(Edge::Top, Anchor::Right)), (980, 0, 940, 150)); + assert_eq!(island_frame(area, 1.0, size, at(Edge::Bottom, Anchor::Left)), (0, 890, 940, 150)); + assert_eq!(island_frame(area, 1.0, size, at(Edge::Bottom, Anchor::Center)), (490, 890, 940, 150)); + assert_eq!(island_frame(area, 1.0, size, at(Edge::Bottom, Anchor::Right)), (980, 890, 940, 150)); + } + + #[test] + fn the_places_follow_a_taskbar_on_any_side_and_any_scale() { + // Taskbar on the left, 62 px wide: the left places start past it. + let left_bar = (62, 0, 1858, 1080); + assert_eq!(island_frame(left_bar, 1.0, (940.0, 150.0), at(Edge::Top, Anchor::Left)), (62, 0, 940, 150)); + assert_eq!(island_frame(left_bar, 1.0, (940.0, 150.0), at(Edge::Bottom, Anchor::Right)), (980, 930, 940, 150)); + // Taskbar on the right: the right places stop before it. + let right_bar = (0, 0, 1858, 1080); + assert_eq!(island_frame(right_bar, 1.0, (940.0, 150.0), at(Edge::Top, Anchor::Right)), (918, 0, 940, 150)); + // Taskbar at the top of a 150 % screen left of the primary one: the top places hang under it, the bottom ones sit on the screen's foot. + let top_bar = (-2880, 72, 2880, 1548); + assert_eq!(island_frame(top_bar, 1.5, (940.0, 150.0), at(Edge::Top, Anchor::Center)), (-2145, 72, 1410, 225)); + assert_eq!(island_frame(top_bar, 1.5, (940.0, 150.0), at(Edge::Bottom, Anchor::Left)), (-2880, 1395, 1410, 225)); + assert_eq!(island_frame(top_bar, 1.5, (940.0, 150.0), at(Edge::Bottom, Anchor::Right)), (-1410, 1395, 1410, 225)); + // A 1366 px laptop at 125 %: the window is narrower than the deck's room, kept on the screen. + let laptop = (0, 0, 1366, 728); + assert_eq!(island_frame(laptop, 1.25, (940.0, 530.0), at(Edge::Top, Anchor::Right)), (191, 0, 1175, 663)); + assert_eq!(island_frame(laptop, 1.25, (940.0, 530.0), at(Edge::Bottom, Anchor::Left)), (0, 65, 1175, 663)); } #[test] fn a_robot_dropped_near_the_top_edge_goes_back_into_the_island() { let area = (0, 40, 1920, 1000); - assert!(docks(area, 1.0, (960, 60)), "just under a top taskbar"); - assert!(docks(area, 1.0, (10, 40 + 71)), "anywhere along the edge"); - assert!(!docks(area, 1.0, (960, 40 + 72)), "a little lower stays on the desktop"); - assert!(docks(area, 2.0, (960, 40 + 140)), "the reach scales with the screen"); - assert!(!docks(area, 1.0, (2000, 60)), "on another screen"); - assert!(!docks((-1920, 0, 1920, 1080), 1.0, (100, 30)), "the neighbour's top is not this one's"); + assert!(docks(area, 1.0, (960, 60), TOP), "just under a top taskbar"); + assert!(docks(area, 1.0, (10, 40 + 71), TOP), "anywhere along the edge"); + assert!(!docks(area, 1.0, (960, 40 + 72), TOP), "a little lower stays on the desktop"); + assert!(docks(area, 2.0, (960, 40 + 140), TOP), "the reach scales with the screen"); + assert!(!docks(area, 1.0, (2000, 60), TOP), "on another screen"); + assert!(!docks((-1920, 0, 1920, 1080), 1.0, (100, 30), TOP), "the neighbour's top is not this one's"); + } + + #[test] + fn a_robot_docks_near_the_edge_and_on_the_side_of_the_island() { + // The taskbar at the bottom, the island at the bottom right. + let area = (0, 0, 1920, 1040); + let place = at(Edge::Bottom, Anchor::Right); + assert!(docks(area, 1.0, (1700, 1000), place), "just above the taskbar, on the right"); + assert!(docks(area, 1.0, (1700, 1060), place), "dropped over the taskbar"); + assert!(!docks(area, 1.0, (1700, 1040 - 73), place), "a little higher stays on the desktop"); + assert!(!docks(area, 1.0, (400, 1000), place), "the left half is not the island's"); + assert!(!docks(area, 1.0, (1700, 30), place), "the top edge is not the island's"); + // The island at the top left of a 200 % screen left of the primary one, under a top taskbar. + let area = (-3840, 96, 3840, 2064); + let place = at(Edge::Top, Anchor::Left); + assert!(docks(area, 2.0, (-3700, 96 + 143), place)); + assert!(!docks(area, 2.0, (-3700, 96 + 145), place)); + assert!(!docks(area, 2.0, (-1000, 120), place), "the right half"); + // A taskbar on the right: the right half ends before it. + assert!(!docks((0, 0, 1858, 1080), 1.0, (1880, 20), at(Edge::Top, Anchor::Right)), "over the taskbar, off the work area"); } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index da6eeee..b579093 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -46,6 +46,7 @@ pub fn run() { companion_window::companion_focus, companion_window::companion_keep, island::island_place, + island::island_span, tray::bots_settings, tray::bots_tray_labels, computer::computer_system, diff --git a/src/lib/companion/companion.svelte.ts b/src/lib/companion/companion.svelte.ts index 6068c8a..352d405 100644 --- a/src/lib/companion/companion.svelte.ts +++ b/src/lib/companion/companion.svelte.ts @@ -29,6 +29,7 @@ import { Music } from './music.svelte'; import { addReminder } from './memory'; import { captureScreens, type ScreenShot } from './screen'; import { playCue, unlockSounds, type Cue } from './sounds'; +import { placeLayout } from '../island/place'; import { bytesOf, companionMonitors, dragCompanion, focusCompanion, inShell, keepFile, showMain } from './shell'; import { Talk } from './talk.svelte'; import { Crew } from './crew.svelte'; @@ -656,8 +657,8 @@ export class Companion { this.close(); this.prefs = writeCompanionPrefs({ robots: 'desktop' }); } - const place = await dragCompanion(fromIsland); - // Dropped against the top edge, the robot goes back into the island of that screen. + const place = await dragCompanion(fromIsland, placeLayout(this.prefs.islandPlace)); + // Dropped near the island's edge and side, the robot goes back into the island of that screen. if (place?.docked) this.prefs = writeCompanionPrefs({ robots: 'island', monitor: place.monitor }); else if (place) this.prefs = writeCompanionPrefs({ robots: 'desktop', monitor: place.monitor, anchor: 'free', spot: place.spot }); } catch { diff --git a/src/lib/companion/shell.ts b/src/lib/companion/shell.ts index c8d0652..2093061 100644 --- a/src/lib/companion/shell.ts +++ b/src/lib/companion/shell.ts @@ -52,9 +52,18 @@ export async function placeCompanion(monitor: string | null, anchor: CompanionAn return placed ?? layoutOf(anchor, spot); } -/** The island: the window hanging from the top centre of `monitor`, `width` × `height` CSS pixels (`island.rs`). */ -export async function placeIsland(monitor: string | null, width: number, height: number): Promise { - return (await invoke('island_place', { monitor, width: Math.ceil(width), height: Math.ceil(height) })) ?? null; +/** The island: the window against the edge and on the side of `place` on `monitor`, `width` × `height` CSS pixels (`island.rs`). */ +export async function placeIsland(monitor: string | null, place: CompanionLayout, width: number, height: number): Promise { + return (await invoke('island_place', { monitor, place, width: Math.ceil(width), height: Math.ceil(height) })) ?? null; +} + +/** + * While the pill is dragged to another place, the window spans the island's + * screen and takes every click (`island_span`); `false` puts it back at the + * island's place, for a drag let go where it began. + */ +export async function spanIsland(span: boolean): Promise { + await invoke('island_span', { span }); } /** Hiding for a full-screen app, and the shortcut; rejects when the shortcut is taken. */ @@ -65,10 +74,11 @@ export async function configureCompanion(hideFullscreen: boolean, hotkey: string /** * Carries the window while the button stays down; null when it did not move. * `detach` pulls the robot out of the island: the window first takes the - * desktop size, the character under the pointer. + * desktop size, the character under the pointer. `island` is the island's + * place, where a robot dropped near it goes back in. */ -export async function dragCompanion(detach = false): Promise { - return (await invoke('companion_drag', { detach })) ?? null; +export async function dragCompanion(detach: boolean, island: CompanionLayout): Promise { + return (await invoke('companion_drag', { detach, place: island })) ?? null; } /** From ecbedf5743d9ca941f3f3dbdd822ebaf8bd49656 Mon Sep 17 00:00:00 2001 From: Chris <76012699+ChrisPlayer@users.noreply.github.com> Date: Sun, 18 Oct 2026 14:21:41 +0200 Subject: [PATCH 3/5] feat(island): open toward the screen at every place and move the island by dragging its pill --- src/App.svelte | 60 ++++++-- src/components/island/Island.svelte | 137 +++++++++++++++--- src/components/island/IslandBar.svelte | 45 +++++- src/components/island/IslandDrop.svelte | 36 +++-- src/components/island/IslandTargets.svelte | 91 ++++++++++++ src/components/island/TimerBubble.svelte | 7 + .../island/music/MusicBubble.svelte | 7 + .../island/settings/SettingsPanel.svelte | 34 ++++- src/lib/island/move.svelte.ts | 118 +++++++++++++++ 9 files changed, 480 insertions(+), 55 deletions(-) create mode 100644 src/components/island/IslandTargets.svelte create mode 100644 src/lib/island/move.svelte.ts diff --git a/src/App.svelte b/src/App.svelte index 141a075..221f599 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -4,11 +4,13 @@ window lets them through to what is under it. It draws one companion (`lib/companion/companion.svelte.ts`) in one of two - ways, as the user chose: the island at the top of the screen + ways, as the user chose: the island at the top or the bottom of the screen (`components/island/`), or the robots loose on the desktop (`components/desktop/`). Dragging a robot out of the island sets it loose; - dragging it back against the top edge, or "Back to the island", returns it. + dragging it back near the island, or "Back to the island", returns it. The shell places the window for either (`island_place`, `companion_place`). + Dragging the pill moves the island to another of its six places + (`lib/island/move.svelte.ts`): the window spans the screen meanwhile. The settings are a panel of the island (`island/settings/SettingsPanel.svelte`): asked for from the tray, a gear or "Make an agent", the island opens for @@ -17,14 +19,18 @@ --> + + + + + + diff --git a/src/components/island/TimerBubble.svelte b/src/components/island/TimerBubble.svelte index 29a28ed..74b25d6 100644 --- a/src/components/island/TimerBubble.svelte +++ b/src/components/island/TimerBubble.svelte @@ -135,6 +135,13 @@ .drop.open { border-radius: 0 0 18px 18px; } + /* Against the bottom edge (`Island.svelte`), rounded at the top. */ + :global([data-island-edge='bottom']) .drop { + border-radius: 16px 16px 0 0; + } + :global([data-island-edge='bottom']) .drop.open { + border-radius: 18px 18px 0 0; + } /* The ring alone: the pill's line tells the time. */ .drop.compact:not(.open) { min-width: 0; diff --git a/src/components/island/music/MusicBubble.svelte b/src/components/island/music/MusicBubble.svelte index 6a406ac..55bf156 100644 --- a/src/components/island/music/MusicBubble.svelte +++ b/src/components/island/music/MusicBubble.svelte @@ -321,6 +321,13 @@ radial-gradient(150px 110px at 44px 44px, color-mix(in srgb, var(--music-accent) 24%, transparent), transparent 72%), var(--island-bg); } + /* Against the bottom edge (`Island.svelte`), rounded at the top. */ + :global([data-island-edge='bottom']) .bubble { + border-radius: 16px 16px 0 0; + } + :global([data-island-edge='bottom']) .bubble.open { + border-radius: 24px 24px 0 0; + } button { padding: 0; border: none; diff --git a/src/components/island/settings/SettingsPanel.svelte b/src/components/island/settings/SettingsPanel.svelte index 27bb469..91f2cc0 100644 --- a/src/components/island/settings/SettingsPanel.svelte +++ b/src/components/island/settings/SettingsPanel.svelte @@ -11,7 +11,9 @@ (`lib/companion/prefs.ts`), written as each one changes. It opens with a reveal from the bar down (clip-path), a short slide and a - fade, all composited: nothing animates its size. + fade, all composited: nothing animates its size. With the island at the + bottom of the screen, the island grows up to hold it under the bar, and it + rises from the screen's edge instead. At a side, it keeps to that side. -->
(null); + /** How far the island is drawn from its place. Same spring as the island's size. */ + readonly offset = new FrameSpring({ x: 0, y: 0 }, { stiffness: 0.18, damping: 0.72, precision: 0.25 }); + /** A drag just ended: the click it ends with opens nothing. */ + moved = false; + #press: (Point & { id: number }) | null = null; + #movedTimer: ReturnType | undefined; + + constructor(private readonly host: MoveHost) {} + + /** A press on the pill: a drag if it moves far enough, a click otherwise. */ + press(event: PointerEvent): void { + if (event.button !== 0 || this.moving) return; + this.#press = { x: event.screenX, y: event.screenY, id: event.pointerId }; + window.addEventListener('pointermove', this.#move); + window.addEventListener('pointerup', this.#release); + window.addEventListener('pointercancel', this.#release); + } + + /** The island landed, its offset back to nothing: the window shrinks around it. */ + settle(): void { + if (!this.landing) return; + this.landing = false; + this.moving = false; + } + + dispose(): void { + this.#stopListening(); + this.offset.stop(); + clearTimeout(this.#movedTimer); + } + + #nearest(): IslandPlace | null { + const box = this.host.island(); + if (!box) return null; + const centre = { x: box.x + box.width / 2, y: box.y + box.height / 2 }; + return nearestPlace(centre, { width: window.innerWidth, height: window.innerHeight }, { width: box.width, height: box.height }); + } + + #move = (event: PointerEvent): void => { + const press = this.#press; + if (!press || event.pointerId !== press.id) return; + const by = { x: event.screenX - press.x, y: event.screenY - press.y }; + if (!this.moving) { + if (Math.hypot(by.x, by.y) < MOVE_FROM) return; + this.moving = true; + this.moved = true; + this.host.span(); + } + this.offset.set(by, { instant: true }); + this.over = this.#nearest(); + }; + + #release = async (event: PointerEvent): Promise => { + if (this.#press && event.pointerId !== this.#press.id) return; + this.#stopListening(); + this.#press = null; + if (!this.moving) return; + clearTimeout(this.#movedTimer); + this.#movedTimer = setTimeout(() => (this.moved = false), 300); + const place = this.#nearest() ?? this.host.place(); + this.over = null; + this.landing = true; + // Where it was let go, then laid out at its new place, the same offset on: the gap between them springs away. + const before = this.host.island(); + const shown = this.offset.current; + this.host.land(place); + await tick(); + const after = this.host.island(); + if (before && after) this.offset.set({ x: before.x + shown.x - after.x, y: before.y + shown.y - after.y }, { instant: true }); + this.offset.set({ x: 0, y: 0 }, { instant: this.host.reduced() }); + }; + + #stopListening(): void { + window.removeEventListener('pointermove', this.#move); + window.removeEventListener('pointerup', this.#release); + window.removeEventListener('pointercancel', this.#release); + } +} From 9c7f0c02915f8571a8558d3bac8cb18998e86c9d Mon Sep 17 00:00:00 2001 From: Chris <76012699+ChrisPlayer@users.noreply.github.com> Date: Sun, 18 Oct 2026 14:21:42 +0200 Subject: [PATCH 4/5] feat(settings): island position picker --- .../island/settings/IslandPlacePick.svelte | 164 ++++++++++++++++++ .../island/settings/PlaceSection.svelte | 6 +- src/lib/strings.en.ts | 3 + src/lib/strings.fr.ts | 3 + 4 files changed, 174 insertions(+), 2 deletions(-) create mode 100644 src/components/island/settings/IslandPlacePick.svelte diff --git a/src/components/island/settings/IslandPlacePick.svelte b/src/components/island/settings/IslandPlacePick.svelte new file mode 100644 index 0000000..8501248 --- /dev/null +++ b/src/components/island/settings/IslandPlacePick.svelte @@ -0,0 +1,164 @@ + + + +
+ {label} +
+
+ {#each ISLAND_PLACES as place (place)} + {@const { edge, side } = placeParts(place)} + + {/each} + +
+ +
+
+ + diff --git a/src/components/island/settings/PlaceSection.svelte b/src/components/island/settings/PlaceSection.svelte index 0501243..ea9f636 100644 --- a/src/components/island/settings/PlaceSection.svelte +++ b/src/components/island/settings/PlaceSection.svelte @@ -1,6 +1,6 @@ @@ -13,6 +13,7 @@ import { readStatus, subscribeCompanionData } from '../../../lib/companion/memory'; import { companionMonitors, type CompanionMonitor } from '../../../lib/companion/shell'; import { writeBarEdit } from '../../../lib/island/bar'; + import IslandPlacePick from './IslandPlacePick.svelte'; import SettingPick from './SettingPick.svelte'; import SettingSwitch from './SettingSwitch.svelte'; @@ -72,6 +73,7 @@ {#if prefs.robots === 'island'}
+ save({ islandPlace })} /> save({ openOnHover })} testid="companion-open-on-hover" /> save({ pillRest: id as PillRest })} testid="companion-pill-rest" />
diff --git a/src/lib/strings.en.ts b/src/lib/strings.en.ts index 49e5c84..f3a4834 100644 --- a/src/lib/strings.en.ts +++ b/src/lib/strings.en.ts @@ -773,6 +773,9 @@ export const strings = { pillRestHint: 'What the closed island shows when nothing else needs it. Requests and finished threads still show over it.', pillRests: { live: 'Live line', quota: 'Quota', music: 'Music', timer: 'Timer', robot: 'Robot only' }, pillRestHints: { live: 'The thread at work and what it does', quota: 'What is left on your roomiest subscription', music: 'Title and artist of what plays, else the live line', timer: 'The running timer or pomodoro, else the live line', robot: 'A short pill, no text' }, + islandPlace: 'Island position', + islandPlaceHint: 'Where the island sits on its screen. You can also drag the closed pill to another place.', + islandPlaces: { 'top-left': 'Top left', 'top-center': 'Top center', 'top-right': 'Top right', 'bottom-left': 'Bottom left', 'bottom-center': 'Bottom center', 'bottom-right': 'Bottom right' }, mascot: 'Mascot', mascotHint: 'The island’s robot, and the first agent’s on the desktop. Pick a style, then make it yours.', mascotReset: 'Use the first agent’s own look', diff --git a/src/lib/strings.fr.ts b/src/lib/strings.fr.ts index d81cf3a..5a0992f 100644 --- a/src/lib/strings.fr.ts +++ b/src/lib/strings.fr.ts @@ -747,6 +747,9 @@ export const fr: Messages = { pillRestHint: 'Ce que montre l’île fermée quand rien d’autre ne la demande. Les demandes et les fils terminés passent toujours devant.', pillRests: { live: 'Ligne en direct', quota: 'Quota', music: 'Musique', timer: 'Minuteur', robot: 'Robot seul' }, pillRestHints: { live: 'Le fil au travail et ce qu’il fait', quota: 'Ce qu’il reste sur votre abonnement le mieux pourvu', music: 'Titre et artiste de ce qui joue, sinon la ligne en direct', timer: 'Le minuteur ou le pomodoro en cours, sinon la ligne en direct', robot: 'Une pilule courte, sans texte' }, + islandPlace: 'Position de l’île', + islandPlaceHint: 'Où l’île se place sur son écran. Vous pouvez aussi faire glisser la pilule fermée vers une autre place.', + islandPlaces: { 'top-left': 'En haut à gauche', 'top-center': 'En haut au centre', 'top-right': 'En haut à droite', 'bottom-left': 'En bas à gauche', 'bottom-center': 'En bas au centre', 'bottom-right': 'En bas à droite' }, mascot: 'Mascotte', mascotHint: 'Le robot de l’île, et celui du premier agent sur le bureau. Choisissez un style, puis personnalisez-le.', mascotReset: 'Reprendre l’allure du premier agent', From 563bfeb97b8fda89c7372a3cd389176794313594 Mon Sep 17 00:00:00 2001 From: Chris <76012699+ChrisPlayer@users.noreply.github.com> Date: Sun, 18 Oct 2026 14:21:42 +0200 Subject: [PATCH 5/5] feat(pose): place parameter and a taskbar for bottom places --- src/lib/island/pose.ts | 32 +++++++++++++++++++++++++++++--- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/src/lib/island/pose.ts b/src/lib/island/pose.ts index 943ee47..7a84487 100644 --- a/src/lib/island/pose.ts +++ b/src/lib/island/pose.ts @@ -64,7 +64,10 @@ * - `pose=decktab&tab=apps|clipboard|wallpaper|system`: one of the deck's * computer tabs alone, in a deck-wide black frame, on a fake computer; its * own states (`q`, `clip`, `source`, `engine`, `press`), in - * `lib/windows/pose.ts`, also apply to `pose=deck`. + * `lib/windows/pose.ts`, also apply to `pose=deck`; + * - `place=top-left|top-center|top-right|bottom-left|bottom-center|bottom-right`: + * the island at that place (`place.ts`), the page playing the screen; at + * the bottom, a thin made-up taskbar under it. * * A wallpaper goes under the transparent window, so the black island shows. */ @@ -80,6 +83,7 @@ import { FAKE_APPS } from '../windows/fake'; import { isDeckTab } from './deck-tabs'; import type { IslandFrame } from './frame.svelte'; import { posed } from './posed.svelte'; +import { parsePlace } from './place'; import { settingsPanel } from './settings-panel.svelte'; import { prLine } from './pr-checks'; import type { PressureReason } from './pressure'; @@ -123,8 +127,9 @@ const LYRICS = `[ti:Midnight Circuit] const NEXT_TRACK = { title: 'Glass Harbor', artist: 'Neon Tides', app: 'Spotify.exe', position: 3_000, duration: 188_000, seekable: true }; function poseMusic(companion: Companion, playing: boolean, art: string | null, lyrics: boolean): void { - // The shell's reads stop, kept so a later write of the prefs keeps them off: the posed track stays. - companion.prefs = writeCompanionPrefs({ music: false, ...(lyrics ? { lyrics: true } : {}) }); + // The shell's reads stop, kept so a later write of the prefs keeps them off: the posed track stays. The posed place stays too. + const islandPlace = companion.prefs.islandPlace; + companion.prefs = { ...writeCompanionPrefs({ music: false, ...(lyrics ? { lyrics: true } : {}) }), islandPlace }; setTimeout(() => { const media = { title: 'Midnight Circuit', artist: 'Neon Tides', app: 'Spotify.exe', playing, art, position: 74_000, duration: 213_000, seekable: true }; companion.music.demo = true; @@ -222,6 +227,23 @@ const SKIN_POSES: Record = { retro: { family: 'retro', shape: 0, color: 7, eyes: 1, top: 0 }, }; +/** A made-up taskbar under a bottom island: the page, which plays the screen, ends above it as a work area does. */ +const TASKBAR = 40; +function poseTaskbar(): void { + const style = document.createElement('style'); + style.textContent = [ + `main[data-testid='companion'] { height: calc(100dvh - ${TASKBAR}px) !important; }`, + `.pose-taskbar { position: fixed; inset: auto 0 0; height: ${TASKBAR}px; display: flex; gap: 8px; align-items: center; justify-content: center; background: rgb(28 30 40 / 0.88); border-top: 1px solid rgb(255 255 255 / 0.08); }`, + '.pose-taskbar i { width: 24px; height: 24px; border-radius: 6px; background: rgb(255 255 255 / 0.18); }' + ].join('\n'); + document.head.append(style); + const bar = document.createElement('div'); + bar.className = 'pose-taskbar'; + bar.setAttribute('aria-hidden', 'true'); + for (let icon = 0; icon < 6; icon++) bar.append(document.createElement('i')); + document.body.append(bar); +} + const WALLPAPER = 'html[data-companion] body { background: linear-gradient(160deg, #34466e, #7d6797 55%, #d49c78) !important; }'; async function until(ready: () => boolean, tries = 60): Promise { @@ -232,7 +254,11 @@ export async function strikePose(companion: Companion, frame: IslandFrame, param const style = document.createElement('style'); style.textContent = WALLPAPER; document.head.append(style); + const place = parsePlace(params.get('place')); + if (place?.startsWith('bottom')) poseTaskbar(); await until(() => companion.threads.length > 0 && companion.crew.members.length > 0); + // Set once the companion has read its own prefs, which would put the island back. + if (place) companion.prefs = { ...companion.prefs, islandPlace: place }; if (params.get('calm') === '1') { for (const request of companion.permissions) await companion.act({ kind: 'allow', request }); for (const question of companion.questions) await companion.act({ kind: 'skip', question });