T46: Ely peeks over the main window's top edge from behind it, as the owner asked after the 0.1.3 film: her head and fingers from the icon's paths in a transparent window of her own, kept on the edge as each platform allows, macOS's child window, Windows' owned popup, X11's popup moved and stacked above the frame, Wayland's anchored popup; ten faces follow the most pressing of the sessions' marks, with hops, a shake and a rise; a press winks; Settings, Terminal, Ely turns her off
CI / platforms (windows-latest) (push) Failing after 10s
CI / platforms (ubuntu-24.04) (push) Successful in 1m52s
CI / macos (push) Successful in 12m52s

This commit is contained in:
ZacharyZhang-NY committed 2026-10-18 05:06:59 -04:00
1 parent b35eb52d9a
commit 9aeb3f3552
22 files changed
+1090 -45

No files matched your search

+1
View File
@@ -136,6 +136,7 @@ pub fn open(launch: Launch, cx: &mut App) -> anyhow::Result<WindowHandle<Workspa
Workspace::new(pty, events, launch, window, cx)
})
})?;
crate::pet::follow(window, cx);
log::info!(
"app: main window open at {} by {}, blur: {background:?} on {}",
f32::from(kept.width),
+1 -36
View File
@@ -1,8 +1,3 @@
use std::{
io::{BufRead, BufReader},
process::{Child, Command, Stdio},
};
use x11rb::{
COPY_DEPTH_FROM_PARENT,
connection::Connection as _,
@@ -10,37 +5,7 @@ use x11rb::{
};
use super::*;
/// An X server of the test's own, gone with it.
struct Xvfb {
child: Child,
display: String,
}
impl Xvfb {
fn start() -> Self {
let mut child = Command::new("Xvfb")
.args(["-displayfd", "1", "-nolisten", "tcp"])
.stdout(Stdio::piped())
.spawn()
.expect("Xvfb starts: the X11 tests need it");
let mut number = String::new();
let stdout = child.stdout.take().expect("Xvfb's stdout");
BufReader::new(stdout)
.read_line(&mut number)
.expect("Xvfb names its display");
let display = format!(":{}", number.trim());
Self { child, display }
}
}
impl Drop for Xvfb {
fn drop(&mut self) {
if let Err(error) = self.child.kill().and_then(|()| self.child.wait().map(drop)) {
eprintln!("Xvfb {} did not stop: {error}", self.display);
}
}
}
use crate::testx11::Xvfb;
#[test]
fn the_window_names_its_pane_while_blurred() {
+3
View File
@@ -3,10 +3,13 @@ pub mod blur;
pub mod files;
pub mod git;
pub mod menus;
pub mod pet;
pub mod sessions;
pub mod settings;
pub mod term;
#[cfg(all(test, unix))]
mod testlog;
#[cfg(all(test, target_os = "linux"))]
mod testx11;
pub mod themes;
pub mod workspace;
+301
View File
@@ -0,0 +1,301 @@
//! 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;
#[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 gpui::{
Animation, AnimationExt as _, App, AppContext as _, Bounds, Context, Decorations, Image,
InteractiveElement, IntoElement, MouseButton, ParentElement, Pixels, Render, Styled,
Subscription, Task, Window, WindowBackgroundAppearance, WindowBounds, WindowHandle, WindowKind,
WindowOptions, div, img, point, popup, px, size,
};
#[cfg(target_os = "macos")]
use self::macos as platform;
#[cfg(windows)]
use self::windows as platform;
#[cfg(target_os = "linux")]
use self::x11 as platform;
use self::{
faces::Face,
look::{Mood, PETTED, face, mood},
};
use crate::{settings::SettingsStore, workspace::Workspace};
/// Her head's box, in points, and the room above it her hops take.
const WIDTH: f32 = 112.0;
const HEAD: f32 = 107.4;
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.
pub struct Pet {
faces: Vec<(Face, Arc<Image>)>,
mood: Mood,
/// When the mood began.
since: Instant,
petted: Option<Instant>,
/// Counts her moves, so each plays from its start.
moves: usize,
/// What keeps her window on the main window's edge, where the platform does not.
glue: Option<platform::Glue>,
_wake: Option<Task<()>>,
_watch: Vec<Subscription>,
}
/// Shows Ely over `main` while the settings want her.
pub fn follow(main: WindowHandle<Workspace>, cx: &mut App) {
let shown: Rc<RefCell<Option<WindowHandle<Pet>>>> = Rc::default();
let sync = move |cx: &mut App| {
let open = *shown.borrow();
match (SettingsStore::get(cx).ely, open) {
(true, None) => match show(main, cx) {
Ok(pet) => *shown.borrow_mut() = Some(pet),
Err(error) => log::error!("pet: Ely does not show: {error:#}"),
},
(false, Some(pet)) => {
*shown.borrow_mut() = None;
log::info!("pet: Ely goes, as the settings say");
if pet
.update(cx, |_, window, _| window.remove_window())
.is_err()
{
log::debug!("pet: her window had gone");
}
}
_ => {}
}
};
sync(cx);
cx.observe_global::<SettingsStore>(sync).detach();
}
/// Opens her window on `main`'s top edge: a popup Wayland's compositor places, else a window kept there.
fn show(main: WindowHandle<Workspace>, cx: &mut App) -> anyhow::Result<WindowHandle<Pet>> {
let (bounds, inset, wayland) = main.update(cx, |_, window, _| {
(window.bounds(), top_inset(window), wayland(window))
})?;
let room = size(px(WIDTH), px(HEAD + ROOM));
let kind = if wayland {
WindowKind::AnchoredPopup(popup::PopupOptions {
parent: main.into(),
anchor_rect: Bounds::new(point(inset + px(LEFT), inset), size(px(1.), px(1.))),
anchor: popup::PopupAnchor::TopLeft,
gravity: popup::PopupGravity::TopRight,
constraint_adjustment: popup::PopupConstraintAdjustment::empty(),
offset: point(px(0.), px(0.)),
grab: false,
})
} else {
WindowKind::PopUp
};
let origin = point(
bounds.origin.x + inset + px(LEFT),
bounds.origin.y + inset - room.height,
);
let options = WindowOptions {
window_bounds: Some(WindowBounds::Windowed(Bounds::new(origin, room))),
titlebar: None,
focus: false,
show: true,
kind,
is_movable: false,
is_resizable: false,
is_minimizable: false,
window_background: WindowBackgroundAppearance::Transparent,
..Default::default()
};
let workspace = main.entity(cx)?;
let pet = cx.open_window(options, |_, cx| {
cx.new(|cx| {
let feel = cx.observe(&workspace, |pet: &mut Pet, workspace, cx| {
let sessions = workspace.read(cx).sessions();
let now = mood(sessions.iter().filter_map(|session| session.looks(cx)));
if now != pet.mood {
pet.feel(now, cx);
}
});
let mut pet = Pet {
faces: faces::images(),
mood: Mood::Calm,
since: Instant::now(),
petted: None,
moves: 0,
glue: None,
_wake: None,
_watch: vec![feel],
};
pet.wake(cx);
pet
})
})?;
if !wayland {
glue(main, pet, cx)?;
}
let way = if wayland {
"a Wayland popup"
} else {
"a window kept there"
};
log::info!("pet: Ely peeks over the main window, {way}");
Ok(pet)
}
/// Keeps her window on `main`'s top edge as it moves, comes forward and hides.
fn glue(main: WindowHandle<Workspace>, pet: WindowHandle<Pet>, cx: &mut App) -> anyhow::Result<()> {
main.update(cx, |_, main, cx| {
pet.update(cx, |pet, window, cx| {
let glue = platform::Glue::new(window, main)?;
glue.place(main)?;
pet.glue = Some(glue);
pet._watch
.push(cx.observe_window_bounds(main, |pet, main, _| pet.place(main)));
pet._watch
.push(cx.observe_window_activation(main, |pet, main, _| pet.place(main)));
#[cfg(target_os = "linux")]
pet._watch.push(
cx.observe_window_visibility(main, |pet, visibility, main, _| {
pet.shown(visibility.is_visible(), main)
}),
);
anyhow::Ok(())
})?
})?
}
/// The pane's distance from its window's top: the resize margin of a window that draws its own frame and is free at the top.
fn top_inset(window: &Window) -> Pixels {
match (window.window_decorations(), window.client_inset()) {
(Decorations::Client { tiling }, Some(inset)) if !tiling.top => inset,
_ => px(0.),
}
}
/// Whether `window` is a Wayland surface, where the compositor places windows.
#[cfg(target_os = "linux")]
fn wayland(window: &Window) -> bool {
use raw_window_handle::{HasWindowHandle, RawWindowHandle};
HasWindowHandle::window_handle(window)
.is_ok_and(|handle| matches!(handle.as_raw(), RawWindowHandle::Wayland(_)))
}
#[cfg(not(target_os = "linux"))]
fn wayland(_: &Window) -> bool {
false
}
impl Pet {
/// Takes up `mood`: a new face, and the move it starts with.
fn feel(&mut self, mood: Mood, cx: &mut Context<Self>) {
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<Duration>) {
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)),
}
}
/// Draws her again when her face changes next.
fn wake(&mut self, cx: &mut Context<Self>) {
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");
}
})
});
}
fn place(&self, main: &Window) {
if let Some(glue) = &self.glue
&& let Err(error) = glue.place(main)
{
log::error!("{error:#}");
}
}
#[cfg(target_os = "linux")]
fn shown(&self, visible: bool, main: &Window) {
if let Some(glue) = &self.glue {
match glue.shown(visible) {
Ok(()) => log::debug!("pet: Ely shows {visible} with the main window"),
Err(error) => log::error!("{error:#}"),
}
}
if visible {
self.place(main);
}
}
}
impl Render for Pet {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> 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(
MouseButton::Left,
cx.listener(|pet, _, _, cx| {
log::debug!("pet: Ely is pressed");
pet.petted = Some(Instant::now());
pet.wake(cx);
cx.notify();
}),
)
}
}
+182
View File
@@ -0,0 +1,182 @@
//! 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<Image>)> {
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#"<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="{height}" viewBox="{x} {y} {w} {h}">{HEAD}{eyes}{blush}{mouth}{HAIR}{brows}{HANDS}{mark}</svg>"#
)
}
/// 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##"<g transform="translate({cx} 188) scale({wide}) translate({} -188)"><ellipse cx="{cx}" cy="188" rx="18" ry="23" fill="#A24FB9"/><g transform="translate({dx} {dy})"><ellipse cx="{cx}" cy="198" rx="13.5" ry="11.5" fill="#EE84C4"/><ellipse cx="{cx}" cy="190" rx="7.5" ry="10.5" fill="#5A2368"/><circle cx="{}" cy="178" r="7" fill="#FFFFFF"/><circle cx="{}" cy="199" r="3.2" fill="#FFFFFF"/></g></g>"##,
-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##"<path fill="#45233F" d="M194 182C195 168 208 159 224 159C234 159 241 164 244 172L240 173C236 168 231 165 224 165C214 165 207 172 204 183Z"/>"##;
const RIGHT_LID: &str = r##"<path fill="#45233F" d="M318 182C317 168 304 159 288 159C278 159 271 164 268 172L272 173C276 168 281 165 288 165C298 165 305 172 308 183Z"/>"##;
const LASHES: &str = r##"<path fill="none" stroke="#C2506F" stroke-width="2" stroke-linecap="round" opacity=".35" d="M215 212Q222 214 229 212M283 212Q290 214 297 212"/>"##;
const LEFT_ARC: &str = r##"<path fill="none" stroke="#45233F" stroke-width="7" stroke-linecap="round" d="M205 197Q222 176 239 197"/>"##;
const SHUT: &str = r##"<g fill="none" stroke="#45233F" stroke-width="6" stroke-linecap="round"><path d="M205 192Q222 203 239 192"/><path d="M273 192Q290 203 307 192"/></g>"##;
const ARCS: &str = r##"<g fill="none" stroke="#45233F" stroke-width="7" stroke-linecap="round"><path d="M205 197Q222 176 239 197"/><path d="M273 197Q290 176 307 197"/></g>"##;
const LINES: &str = r##"<g fill="none" stroke="#45233F" stroke-width="6" stroke-linecap="round"><path d="M205 194H239"/><path d="M273 194H307"/></g>"##;
const BROWS: &str = r##"<g fill="none" stroke="#45233F" stroke-width="5" stroke-linecap="round"><path d="M206 160L234 152"/><path d="M306 160L278 152"/></g>"##;
const SMILE: &str = r##"<path fill="none" stroke="#C2506F" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round" d="M244 222Q250 229 256 223Q262 229 268 222"/>"##;
const GRIN: &str = r##"<path fill="#C2506F" d="M243 219Q256 241 269 219Z"/><path fill="#FF8DAA" d="M249 229Q256 236 263 229Q256 225 249 229Z"/>"##;
const GASP: &str = r##"<ellipse cx="256" cy="226" rx="5.5" ry="7" fill="#C2506F"/>"##;
const FLAT: &str = r##"<path fill="none" stroke="#C2506F" stroke-width="3.5" stroke-linecap="round" d="M249 225H263"/>"##;
const FROWN: &str = r##"<path fill="none" stroke="#C2506F" stroke-width="3.5" stroke-linecap="round" d="M246 228Q256 220 266 228"/>"##;
const BLUSH: &str = r##"<g opacity=".55" fill="#FF8DAA"><ellipse cx="198" cy="216" rx="15" ry="8"/><ellipse cx="314" cy="216" rx="15" ry="8"/></g>"##;
const FLUSH: &str = r##"<g opacity=".85" fill="#FF7A9C"><ellipse cx="198" cy="216" rx="17" ry="9"/><ellipse cx="314" cy="216" rx="17" ry="9"/></g>"##;
const BANG: &str = r##"<g fill="#FFB020" stroke="#45233F" stroke-width="3"><path d="M371 38L385 38L380 86L376 86Z"/><circle cx="378" cy="100" r="7"/></g>"##;
const SNORE: &str = r##"<g fill="none" stroke="#9FA3D9" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"><path d="M352 70H372L352 92H372"/><path d="M380 38H394L380 54H394"/></g>"##;
const HEART: &str = r##"<path fill="#FF5C93" d="M378 70C366 56 350 66 356 80C360 90 378 100 378 100C378 100 396 90 400 80C406 66 390 56 378 70Z"/>"##;
const SPARKLES: &str = r##"<g fill="#FFE48A"><path d="M140 70Q143 84 156 86Q143 88 140 102Q137 88 124 86Q137 84 140 70Z"/><path d="M380 52Q382 62 392 64Q382 66 380 76Q378 66 368 64Q378 62 380 52Z"/></g>"##;
const DROP: &str = r##"<path fill="#9CD5FF" stroke="#5AA0D8" stroke-width="2" d="M346 128Q356 144 350 152Q340 156 338 146Q338 138 346 128Z"/>"##;
/// Her hair behind, her collar, and her face: the icon's paths.
const HEAD: &str = concat!(
r##"<defs><clipPath id="face"><path d="M178 164C178 116 212 94 256 94C300 94 334 116 334 164C334 202 312 228 284 238C274 242 264 244 256 244C248 244 238 242 228 238C200 228 178 202 178 164Z"/></clipPath></defs>"##,
r##"<path fill="#EC86B5" d="M256 46C192 46 142 90 136 158C131 214 126 262 118 306L394 306C386 262 381 214 376 158C370 90 320 46 256 46Z"/>"##,
r##"<path fill="#D9659A" d="M194 200C198 238 214 262 230 276L282 276C298 262 314 238 318 200Z"/>"##,
r##"<path fill="#F7CDBE" d="M236 222L276 222L276 266C268 270 244 270 236 266Z"/>"##,
r##"<path fill="#FFFFFF" d="M198 284C204 268 222 258 238 256L256 272L274 256C290 258 308 268 314 284Z"/>"##,
r##"<path fill="#F05A97" d="M256 264C244 250 226 248 224 258C222 270 236 278 256 264ZM256 264C268 250 286 248 288 258C290 270 276 278 256 264Z"/>"##,
r##"<circle cx="256" cy="264" r="8" fill="#D94683"/>"##,
r##"<path fill="#FFE7DC" d="M178 164C178 116 212 94 256 94C300 94 334 116 334 164C334 202 312 228 284 238C274 242 264 244 256 244C248 244 238 242 228 238C200 228 178 202 178 164Z"/>"##,
r##"<g clip-path="url(#face)"><path fill="#F9D8CB" transform="translate(0 12)" d="M170 176C162 110 204 62 256 62C308 62 350 110 342 176Q328 152 318 134Q316 156 304 172Q298 150 286 130Q286 156 274 178Q268 150 256 128Q244 150 238 178Q226 156 226 130Q214 150 208 172Q196 156 194 134Q184 152 170 176Z"/></g>"##,
);
/// Her side locks, bangs, ahoge and the star in her hair: the icon's paths.
const HAIR: &str = concat!(
r##"<path fill="#F7A6C9" d="M184 118C164 150 156 204 160 258C162 282 156 300 148 316C164 312 178 300 186 286C190 256 192 214 198 176C200 152 196 132 184 118ZM328 118C348 150 356 204 352 258C350 282 356 300 364 316C348 312 334 300 326 286C322 256 320 214 314 176C312 152 316 132 328 118Z"/>"##,
r##"<path fill="#F7A6C9" d="M170 176C162 110 204 62 256 62C308 62 350 110 342 176Q328 152 318 134Q316 156 304 172Q298 150 286 130Q286 156 274 178Q268 150 256 128Q244 150 238 178Q226 156 226 130Q214 150 208 172Q196 156 194 134Q184 152 170 176Z"/>"##,
r##"<path fill="#FFD1E4" d="M196 108C220 84 292 84 316 108C292 92 220 92 196 108Z"/>"##,
r##"<path fill="#F7A6C9" d="M250 66C238 40 252 18 280 20C264 28 258 44 262 64Z"/>"##,
r##"<path fill="#FFE48A" d="M318 86Q320 98 332 100Q320 102 318 114Q316 102 304 100Q316 98 318 86Z"/>"##,
);
/// Her fingers over the edge she holds.
const HANDS: &str = concat!(
r##"<path fill="#FFE7DC" d="M150 286L150 272C150 264 161 264 161 272C161 262 172 262 172 271C172 262 183 262 183 272C183 264 194 264 194 273L194 286ZM318 286L318 273C318 264 329 264 329 272C329 262 340 262 340 271C340 262 351 262 351 272C351 264 362 264 362 272L362 286Z"/>"##,
r##"<path fill="none" stroke="#F2BBA8" stroke-width="2" stroke-linecap="round" d="M161 274V286M172 273V286M183 274V286M329 274V286M340 273V286M351 274V286"/>"##,
);
#[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));
}
}
+149
View File
@@ -0,0 +1,149 @@
//! What Ely feels from the sessions' marks, and the face she makes at each moment of it.
use std::time::Duration;
use super::faces::Face;
use crate::sessions::orb::Look;
/// What the sessions ask of her, least pressing first.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub enum Mood {
Calm,
Done,
Working,
Error,
Waiting,
}
/// The most pressing of the marks' looks; calm with none.
pub fn mood(looks: impl IntoIterator<Item = Look>) -> Mood {
looks
.into_iter()
.map(|look| match look {
Look::Waiting => Mood::Waiting,
Look::Error => Mood::Error,
Look::Working(_) => Mood::Working,
Look::Done => Mood::Done,
Look::Idle => Mood::Calm,
})
.max()
.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);
/// 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),
],
);
/// 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 face `held` into `mood`, and how long it holds; none while it holds for good.
pub fn face(mood: Mood, held: Duration) -> (Face, Option<Duration>) {
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)))
}
}
}
/// 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<Duration>) {
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)))
}
#[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"
);
}
}
+78
View File
@@ -0,0 +1,78 @@
//! Ely's window on macOS: a child of the main window at its level, so it moves with it and keeps its layer and Space.
use anyhow::bail;
use gpui::Window;
use objc::{
msg_send,
runtime::{NO, Object},
sel, sel_impl,
};
use raw_window_handle::{HasWindowHandle, RawWindowHandle};
use super::LEFT;
/// AppKit's `NSWindowAbove`.
const ABOVE: isize = 1;
#[repr(C)]
#[derive(Clone, Copy)]
struct Point {
x: f64,
y: f64,
}
#[repr(C)]
#[derive(Clone, Copy)]
struct Rect {
origin: Point,
width: f64,
height: f64,
}
/// Her NSWindow and the main one's.
pub struct Glue {
pet: *mut Object,
main: *mut Object,
}
impl Glue {
/// Makes her window the main window's child, at its level and in its Spaces, with no shadow.
pub fn new(pet: &Window, main: &Window) -> anyhow::Result<Self> {
let (pet, main) = (native(pet)?, native(main)?);
unsafe {
let level: isize = msg_send![main, level];
let _: () = msg_send![pet, setLevel: level];
let spaces: usize = msg_send![main, collectionBehavior];
let _: () = msg_send![pet, setCollectionBehavior: spaces];
let _: () = msg_send![pet, setHasShadow: NO];
let _: () = msg_send![main, addChildWindow: pet ordered: ABOVE];
}
Ok(Self { pet, main })
}
/// Stands her on the main window's top edge, `LEFT` points in from its left.
pub fn place(&self, _: &Window) -> anyhow::Result<()> {
unsafe {
let frame: Rect = msg_send![self.main, frame];
let at = Point {
x: frame.origin.x + f64::from(LEFT),
y: frame.origin.y + frame.height,
};
let _: () = msg_send![self.pet, setFrameOrigin: at];
}
Ok(())
}
}
/// The NSWindow gpui made for `window`.
fn native(window: &Window) -> anyhow::Result<*mut Object> {
let handle = HasWindowHandle::window_handle(window)
.map_err(|error| anyhow::anyhow!("pet: no native handle: {error:?}"))?;
let RawWindowHandle::AppKit(appkit) = handle.as_raw() else {
bail!("pet: not an AppKit window");
};
let view = appkit.ns_view.as_ptr() as *mut Object;
let native: *mut Object = unsafe { msg_send![view, window] };
anyhow::ensure!(!native.is_null(), "pet: the view is in no window");
Ok(native)
}
+66
View File
@@ -0,0 +1,66 @@
//! Ely's window on Windows: owned by the main window, so it stays above it and hides with it, and moved as it moves.
use anyhow::bail;
use gpui::Window;
use raw_window_handle::{HasWindowHandle, RawWindowHandle};
use windows_sys::Win32::{
Foundation::{HWND, RECT},
UI::WindowsAndMessaging::{
GWLP_HWNDPARENT, GetWindowRect, HWND_NOTOPMOST, SWP_NOACTIVATE, SWP_NOMOVE, SWP_NOSIZE,
SWP_NOZORDER, SetWindowLongPtrW, SetWindowPos,
},
};
use super::{HEAD, LEFT, ROOM};
/// Her window and the main one.
pub struct Glue {
pet: HWND,
main: HWND,
}
impl Glue {
/// Gives her window the main window as owner, and takes away the topmost place gpui gives a popup.
pub fn new(pet: &Window, main: &Window) -> anyhow::Result<Self> {
let (pet, main) = (native(pet)?, native(main)?);
unsafe {
SetWindowLongPtrW(pet, GWLP_HWNDPARENT, main as isize);
let flags = SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE;
anyhow::ensure!(
SetWindowPos(pet, HWND_NOTOPMOST, 0, 0, 0, 0, flags) != 0,
"pet: her window keeps the topmost place"
);
}
Ok(Self { pet, main })
}
/// Stands her on the main window's top edge, `LEFT` points in from its left.
pub fn place(&self, main: &Window) -> anyhow::Result<()> {
let scale = main.scale_factor();
let mut frame = RECT::default();
unsafe {
anyhow::ensure!(
GetWindowRect(self.main, &mut frame) != 0,
"pet: the main window's place does not read"
);
let x = frame.left + (LEFT * scale).round() as i32;
let y = frame.top - ((HEAD + ROOM) * scale).round() as i32;
let flags = SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE;
anyhow::ensure!(
SetWindowPos(self.pet, std::ptr::null_mut(), x, y, 0, 0, flags) != 0,
"pet: her window does not move"
);
}
Ok(())
}
}
/// The HWND gpui made for `window`.
fn native(window: &Window) -> anyhow::Result<HWND> {
let handle = HasWindowHandle::window_handle(window)
.map_err(|error| anyhow::anyhow!("pet: no native handle: {error:?}"))?;
let RawWindowHandle::Win32(win32) = handle.as_raw() else {
bail!("pet: not a Win32 window");
};
Ok(win32.hwnd.get() as HWND)
}
+193
View File
@@ -0,0 +1,193 @@
//! Ely's window on X11: gpui's popup, which no window manager places, put on the main window's top edge and just above it.
use anyhow::{Context as _, bail};
use gpui::Window;
use raw_window_handle::{HasWindowHandle, RawWindowHandle};
use x11rb::{
protocol::xproto::{ConfigureWindowAux, ConnectionExt as _, StackMode},
rust_connection::RustConnection,
};
use super::{HEAD, LEFT, ROOM, top_inset};
/// Her X window, the main one, and a connection of her own to their server.
pub struct Glue {
connection: RustConnection,
pet: u32,
main: u32,
}
impl Glue {
pub fn new(pet: &Window, main: &Window) -> anyhow::Result<Self> {
let (pet, main) = (native(pet)?, native(main)?);
// gpui opens $DISPLAY too; a window's place belongs to whoever asks.
let (connection, _) = x11rb::connect(None).context("pet: no X server")?;
Ok(Self {
connection,
pet,
main,
})
}
/// Stands her on the main window's pane, `LEFT` points in, stacked just above the main window's frame.
pub fn place(&self, main: &Window) -> anyhow::Result<()> {
self.put(main.scale_factor(), f32::from(top_inset(main)))
}
/// Places her at `scale` over a pane `inset` points into the main window.
fn put(&self, scale: f32, inset: f32) -> anyhow::Result<()> {
let (root, frame) = self.frame()?;
let at = (self.connection)
.translate_coordinates(self.main, root, 0, 0)?
.reply()
.context("pet: the main window's place")?;
let aux = ConfigureWindowAux::new()
.x(i32::from(at.dst_x) + ((LEFT + inset) * scale).round() as i32)
.y(i32::from(at.dst_y) + ((inset - HEAD - ROOM) * scale).round() as i32)
.sibling(frame)
.stack_mode(StackMode::ABOVE);
(self.connection)
.configure_window(self.pet, &aux)?
.check()
.context("pet: the X server did not move her window")
}
/// Maps her window while the main one shows, and unmaps it while that hides.
pub fn shown(&self, visible: bool) -> anyhow::Result<()> {
let cookie = if visible {
self.connection.map_window(self.pet)?
} else {
self.connection.unmap_window(self.pet)?
};
cookie
.check()
.context("pet: her window did not map or unmap")
}
/// The root, and the main window's top window under it: the frame a window manager put round it, or itself.
fn frame(&self) -> anyhow::Result<(u32, u32)> {
let mut window = self.main;
loop {
let tree = self.connection.query_tree(window)?.reply()?;
if tree.parent == tree.root {
return Ok((tree.root, window));
}
window = tree.parent;
}
}
}
/// The X window gpui made for `window`.
fn native(window: &Window) -> anyhow::Result<u32> {
let handle = HasWindowHandle::window_handle(window)
.map_err(|error| anyhow::anyhow!("pet: no native handle: {error:?}"))?;
let RawWindowHandle::Xcb(xcb) = handle.as_raw() else {
bail!("pet: not an X11 window");
};
Ok(xcb.window.get())
}
#[cfg(test)]
mod tests {
use x11rb::{
COPY_DEPTH_FROM_PARENT,
connection::Connection as _,
protocol::xproto::{CreateWindowAux, MapState, WindowClass},
};
use super::*;
use crate::testx11::Xvfb;
#[test]
fn she_stands_on_the_pane_just_above_the_frame_and_hides_with_it() {
let xvfb = Xvfb::start();
let (connection, screen) = x11rb::connect(Some(&xvfb.display)).expect("the test connects");
let root = connection.setup().roots[screen].root;
let window =
|parent, (x, y, width, height): (i16, i16, u16, u16), aux: &CreateWindowAux| {
let id = connection.generate_id().expect("a window id");
let class = WindowClass::INPUT_OUTPUT;
(connection.create_window(
COPY_DEPTH_FROM_PARENT,
id,
parent,
x,
y,
width,
height,
0,
class,
0,
aux,
))
.expect("the request")
.check()
.expect("a window");
connection
.map_window(id)
.expect("the request")
.check()
.expect("mapped");
id
};
let plain = CreateWindowAux::new();
let pet = window(
root,
(0, 0, 112, 124),
&CreateWindowAux::new().override_redirect(1),
);
// A window manager's frame, its title bar 20 pixels tall, round the main window.
let frame = window(root, (100, 80, 800, 620), &plain);
let main = window(frame, (0, 20, 800, 600), &plain);
window(root, (0, 0, 50, 50), &plain);
let hers = x11rb::connect(Some(&xvfb.display)).expect("hers").0;
let glue = Glue {
connection: hers,
pet,
main,
};
glue.put(2.0, 10.0).expect("she is placed");
let check = x11rb::connect(Some(&xvfb.display))
.expect("the check connects")
.0;
let at = check
.get_geometry(pet)
.expect("the request")
.reply()
.expect("her place");
let left = (
100 + ((LEFT + 10.0) * 2.0).round() as i32,
100 + ((10.0 - HEAD - ROOM) * 2.0).round() as i32,
);
assert_eq!((i32::from(at.x), i32::from(at.y)), left);
let order = check
.query_tree(root)
.expect("the request")
.reply()
.expect("the stack")
.children;
let place = |id| {
order
.iter()
.position(|&child| child == id)
.expect("on the root")
};
assert_eq!(
place(pet),
place(frame) + 1,
"just above the frame: {order:?}"
);
let mapped = |check: &x11rb::rust_connection::RustConnection| {
check
.get_window_attributes(pet)
.expect("the request")
.reply()
.expect("her state")
.map_state
};
glue.shown(false).expect("hidden");
assert_eq!(mapped(&check), MapState::UNMAPPED);
glue.shown(true).expect("shown");
assert_ne!(mapped(&check), MapState::UNMAPPED);
}
}
+8 -3
View File
@@ -39,11 +39,16 @@ pub struct Session {
}
impl Session {
/// Notes the look its mark takes `now`; true while the mark moves.
pub fn note(&mut self, now: Instant, cx: &App) -> bool {
/// The look its mark takes from its terminal now.
pub fn looks(&self, cx: &App) -> Option<Look> {
let view = self.terminal.read(cx);
let failed = view.ended().is_some_and(|code| code != 0);
let shows = look(view.records().shown().map(|(_, record)| record), failed);
look(view.records().shown().map(|(_, record)| record), failed)
}
/// Notes the look its mark takes `now`; true while the mark moves.
pub fn note(&mut self, now: Instant, cx: &App) -> bool {
let shows = self.looks(cx);
if self.look.map(|(shown, _)| shown) != shows {
self.look = shows.map(|shows| (shows, now));
}
+3
View File
@@ -72,6 +72,8 @@ pub struct Settings {
pub scrollback: u32,
pub option_as_alt: OptionAsAlt,
pub copy_on_select: bool,
/// Ely peeks over the main window's top edge.
pub ely: bool,
pub show_sessions: bool,
pub show_panel: bool,
pub panel: PanelTab,
@@ -103,6 +105,7 @@ impl Default for Settings {
scrollback: 10_000,
option_as_alt: OptionAsAlt::Off,
copy_on_select: false,
ely: true,
show_sessions: true,
show_panel: true,
panel: PanelTab::Git,
+8 -1
View File
@@ -99,7 +99,14 @@ impl SettingsWindow {
settings.copy_on_select,
|settings, on| settings.copy_on_select = on,
);
let section = section.row(SettingsRow::new("Copy on select").control(copy));
let ely = switch(&this, "ely", settings.ely, |settings, on| settings.ely = on);
let section = section
.row(SettingsRow::new("Copy on select").control(copy))
.row(
SettingsRow::new("Ely")
.description("Peeks over the window, her face as your sessions are")
.control(ely),
);
let keys = SettingsSection::new("Shortcuts")
.row(KeyboardShortcutsList::new(shortcuts(cx), &self.search));
div().flex().flex_col().gap_6().child(section).child(keys)
+37
View File
@@ -0,0 +1,37 @@
//! Xvfb for the X11 tests.
use std::{
io::{BufRead, BufReader},
process::{Child, Command, Stdio},
};
/// An X server of the test's own, gone with it.
pub struct Xvfb {
child: Child,
pub display: String,
}
impl Xvfb {
pub fn start() -> Self {
let mut child = Command::new("Xvfb")
.args(["-displayfd", "1", "-nolisten", "tcp"])
.stdout(Stdio::piped())
.spawn()
.expect("Xvfb starts: the X11 tests need it");
let mut number = String::new();
let stdout = child.stdout.take().expect("Xvfb's stdout");
BufReader::new(stdout)
.read_line(&mut number)
.expect("Xvfb names its display");
let display = format!(":{}", number.trim());
Self { child, display }
}
}
impl Drop for Xvfb {
fn drop(&mut self) {
if let Err(error) = self.child.kill().and_then(|()| self.child.wait().map(drop)) {
eprintln!("Xvfb {} did not stop: {error}", self.display);
}
}
}
+5
View File
@@ -263,6 +263,11 @@ impl Workspace {
}
}
/// Its sessions, in their rows' order.
pub fn sessions(&self) -> &[Session] {
&self.sessions
}
/// The shown session's title, or the app's name while its program names none.
fn title(&self) -> &str {
self.sessions