feat: window picker

This commit is contained in:
elwrcl committed 2026-10-03 21:21:18 +03:00
1 parent c6736b0ac3
commit 7fc477b902
7 files changed
+530 -20

No files matched your search

+32 -13
View File
@@ -13,8 +13,6 @@ mod lock;
mod log;
mod niri;
mod output;
// The picker lands in pieces; Task 6 of the theme plan removes this.
#[allow(dead_code)]
mod picker;
mod region;
mod render;
@@ -67,8 +65,11 @@ enum Command {
#[command(flatten)]
common: Common,
},
/// Click a window to capture it (Cmd+Shift+4, then Space).
/// Pick a window from cards over the paint shader (Alt+Tab-like).
Window {
/// Click the window on screen instead (niri's pick).
#[arg(long)]
pick: bool,
#[command(flatten)]
common: Common,
},
@@ -159,9 +160,14 @@ fn parse_delay(s: &str) -> Result<Duration, String> {
type Shots = (Vec<(RgbaImage, Option<String>)>, usize, String);
enum Mode {
Screen { all: bool },
Screen {
all: bool,
},
Region,
Window,
/// `pick`: niri's click-a-window instead of valw's picker.
Window {
pick: bool,
},
Zoom,
}
@@ -206,7 +212,7 @@ fn run(cli: Cli) -> Result<()> {
Command::PreviewHost => host::run(),
Command::Screen { all, common } => capture(Mode::Screen { all }, common),
Command::Region { common } => capture(Mode::Region, common),
Command::Window { common } => capture(Mode::Window, common),
Command::Window { pick, common } => capture(Mode::Window { pick }, common),
Command::Zoom { common } => capture(Mode::Zoom, common),
Command::Edit { file } => editor::run(&file),
// Handled in main, before logging starts.
@@ -272,13 +278,18 @@ fn capture(mode: Mode, common: Common) -> Result<()> {
region::Choice::Window => {
drop(frames);
windowed = true;
window_shot(&outputs, cursor)?
window_shot(&outputs, cursor, None)?
}
}
}
Mode::Window => {
Mode::Window { pick } => {
windowed = true;
window_shot(&outputs, cursor)?
let chosen = if pick {
None
} else {
Some(picker::run(&mut wl, &outputs[focused_output(&outputs)])?)
};
window_shot(&outputs, cursor, chosen)?
}
Mode::Zoom => {
let focused = focused_output(&outputs);
@@ -327,8 +338,15 @@ fn capture(mode: Mode, common: Common) -> Result<()> {
/// A window shot, shaped like the other modes' results. The preview goes to
/// the window's output, or niri's focused one if that is unknown.
fn window_shot(outputs: &[wayland::Output], cursor: bool) -> Result<Shots> {
let (image, output) = window::capture(cursor)?;
fn window_shot(
outputs: &[wayland::Output],
cursor: bool,
chosen: Option<niri_ipc::Window>,
) -> Result<Shots> {
let (image, output) = match chosen {
Some(w) => window::capture_window(&w, cursor)?,
None => window::capture(cursor)?,
};
let source = output.unwrap_or_else(|| outputs[focused_output(outputs)].geom.name.clone());
Ok((vec![(image, None)], 0, source))
}
@@ -337,7 +355,7 @@ fn window_shot(outputs: &[wayland::Output], cursor: bool) -> Result<Shots> {
fn from_toolbar(pick: toolbar::Picked) -> (Mode, Common) {
let mode = match pick.mode {
toolbar::state::Mode::Screen => Mode::Screen { all: false },
toolbar::state::Mode::Window => Mode::Window,
toolbar::state::Mode::Window => Mode::Window { pick: false },
toolbar::state::Mode::Region => Mode::Region,
toolbar::state::Mode::Zoom => Mode::Zoom,
};
@@ -452,6 +470,7 @@ mod tests {
assert!(parses(&["__clipboard"]));
assert!(parses(&["toolbar"]));
assert!(parses(&["backdrop"]));
assert!(parses(&["window", "--pick"]));
assert!(parses(&["__combo-demo"]));
assert!(parses(&["__check-config", "c.toml"]));
assert!(!parses(&["__check-config"]));
@@ -474,7 +493,7 @@ mod tests {
);
assert!(matches!(
from_toolbar(pick(T::Window, false, true)).0,
Mode::Window
Mode::Window { pick: false }
));
assert!(matches!(
from_toolbar(pick(T::Region, false, true)).0,
-2
View File
@@ -108,7 +108,6 @@ pub fn is_our_capture(event: &Event, path: &Path) -> bool {
}
/// Every window niri has.
#[allow(dead_code)] // the picker (theme plan Task 6) removes this
pub fn windows() -> Result<Vec<Window>> {
match request(Request::Windows)? {
Response::Windows(windows) => Ok(windows),
@@ -118,7 +117,6 @@ pub fn windows() -> Result<Vec<Window>> {
/// `windows` most recently used first: the focused one, then by focus
/// time, then those never focused, by id.
#[allow(dead_code)] // the picker (theme plan Task 6) removes this
pub fn mru(mut windows: Vec<Window>) -> Vec<Window> {
windows.sort_by_key(|w| {
let at = w.focus_timestamp.map(|t| (t.secs, t.nanos));
+466
View File
@@ -1,5 +1,471 @@
//! `valw window`: an Alt+Tab-style window picker over the paint shader.
//! Cards for every window (most recently used first); the shader takes the
//! selected window's icon colours; Enter or a click captures that window.
pub mod icons;
pub mod layout;
pub mod state;
use std::time::{Duration, Instant};
use anyhow::{Context, Result};
use image::RgbaImage;
use niri_ipc::Window;
use smithay_client_toolkit::{
compositor::FrameCallbackData,
seat::{
keyboard::{Keysym, Modifiers},
pointer::{BTN_LEFT, PointerEvent, PointerEventKind},
},
shell::{
WaylandSurface,
wlr_layer::{Anchor, KeyboardInteractivity, Layer, LayerSurface},
},
};
use tiny_skia::{FillRule, Paint as SkPaint, Pixmap, PixmapPaint, Transform};
use wayland_client::{Connection, QueueHandle, protocol::wl_surface::WlSurface};
use wayland_protocols::wp::cursor_shape::v1::client::wp_cursor_shape_device_v1::{
Shape, WpCursorShapeDeviceV1,
};
use wayland_protocols::wp::viewporter::client::wp_viewport::WpViewport;
use self::layout::Rect;
use crate::editor::text;
use crate::error::{Cancelled, HintExt};
use crate::theme::Palette;
use crate::theme::gl::{Frame, Paint};
use crate::wayland::{Output, State, Wayland};
pub const NAMESPACE: &str = "valw-picker";
const ICON: f32 = 96.0;
const SPEED: f32 = 0.6;
const EASE: Duration = Duration::from_millis(300);
/// One window as a card.
struct Card {
window: Window,
icon: Option<Pixmap>,
palette: Palette,
title: String,
app: String,
}
pub struct Picker {
// Dropped first: the EGL surface goes before the wl_surface under it.
paint: Option<Paint>,
layer: LayerSurface,
viewport: WpViewport,
scale: f64,
/// Logical size once configured.
size: Option<(u32, u32)>,
cards: Vec<Card>,
selected: usize,
motion: f32,
start: Instant,
from: Palette,
changed: Instant,
overlay_dirty: bool,
shift: bool,
outcome: Option<Option<usize>>,
error: Option<anyhow::Error>,
}
/// Shows the picker on `output` and returns the chosen window.
pub fn run(wl: &mut Wayland, output: &Output) -> Result<Window> {
let windows = crate::niri::mru(crate::niri::windows()?);
anyhow::ensure!(!windows.is_empty(), "there are no windows to capture");
let px = (ICON as f64 * output.scale).round() as u32;
let dirs = icons::Dirs::from_env();
let cards: Vec<Card> = windows
.into_iter()
.map(|window| {
let app_id = window.app_id.clone().unwrap_or_default();
let image = icons::find(&app_id, &dirs).and_then(|p| icons::load(&p, px));
let palette = image
.as_ref()
.map(|i| crate::theme::from_image(i, true))
.unwrap_or(Palette::DEFAULT);
Card {
title: window.title.clone().unwrap_or_default(),
app: app_id,
icon: image.as_ref().and_then(to_pixmap),
palette,
window,
}
})
.collect();
let qh = wl.queue.handle();
let s = &wl.state;
let layer_shell = s
.layer_shell
.as_ref()
.context("compositor does not support wlr-layer-shell")
.hint("run `valw doctor` to see what the compositor supports")?;
let viewporter = s
.viewporter
.as_ref()
.context("compositor does not support wp-viewporter")
.hint("run `valw doctor` to see what the compositor supports")?;
let surface = s.compositor.create_surface(&qh);
let layer = layer_shell.create_layer_surface(
&qh,
surface,
Layer::Overlay,
Some(NAMESPACE),
Some(&output.wl),
);
layer.set_anchor(Anchor::all());
layer.set_exclusive_zone(-1);
layer.set_keyboard_interactivity(KeyboardInteractivity::Exclusive);
layer.commit();
let viewport = viewporter.get_viewport(layer.wl_surface(), &qh, ());
let motion = state::next_motion(&state::default_path());
let now = Instant::now();
let first = cards[0].palette;
wl.state.picker = Some(Picker {
paint: None,
layer,
viewport,
scale: output.scale,
size: None,
cards,
selected: 0,
motion,
start: now,
from: first,
changed: now,
overlay_dirty: true,
shift: false,
outcome: None,
error: None,
});
let result = wl.dispatch_blocking(|s| {
s.picker
.as_ref()
.is_some_and(|p| p.outcome.is_some() || p.error.is_some())
});
// Keep only the answer; dropping the picker destroys its surface, which
// must be gone before anything is captured.
let ended = wl.state.picker.take().map(|p| {
let chosen = p.outcome.flatten().map(|i| p.cards[i].window.clone());
(chosen, p.error)
});
let _ = wl.conn.flush();
let _ = wl.queue.roundtrip(&mut wl.state);
result?;
match ended {
Some((_, Some(e))) => Err(e),
Some((Some(window), None)) => Ok(window),
_ => Err(Cancelled.into()),
}
}
fn to_pixmap(img: &RgbaImage) -> Option<Pixmap> {
let mut p = Pixmap::new(img.width(), img.height())?;
for (dst, src) in p.pixels_mut().iter_mut().zip(img.pixels()) {
let [r, g, b, a] = src.0;
*dst = tiny_skia::ColorU8::from_rgba(r, g, b, a).premultiply();
}
Some(p)
}
impl Picker {
fn is(&self, surface: &WlSurface) -> bool {
self.layer.wl_surface() == surface
}
fn rects(&self) -> Vec<Rect> {
let (w, h) = self.size.unwrap_or((1, 1));
layout::cards(self.cards.len(), (w as f32, h as f32), self.selected)
}
fn palette(&self, now: Instant) -> Palette {
let to = self.cards[self.selected].palette;
self.from
.lerp(&to, (now - self.changed).as_secs_f32() / EASE.as_secs_f32())
}
fn select(&mut self, i: usize) {
if i != self.selected {
let now = Instant::now();
self.from = self.palette(now);
self.changed = now;
self.selected = i;
self.overlay_dirty = true;
}
}
pub fn configure(
&mut self,
conn: &Connection,
layer: &LayerSurface,
(w, h): (u32, u32),
qh: &QueueHandle<State>,
) {
if !self.is(layer.wl_surface()) || w == 0 || h == 0 {
return;
}
self.size = Some((w, h));
let px = (
(w as f64 * self.scale).round() as u32,
(h as f64 * self.scale).round() as u32,
);
self.viewport.set_destination(w as i32, h as i32);
match &mut self.paint {
Some(paint) => paint.resize(px),
None => match Paint::new(conn, self.layer.wl_surface(), px) {
Ok(paint) => self.paint = Some(paint),
Err(e) => {
self.error = Some(e.context("could not start OpenGL ES for the picker"));
return;
}
},
}
self.overlay_dirty = true;
self.draw(qh);
}
pub fn closed(&mut self, layer: &LayerSurface) {
if self.is(layer.wl_surface()) {
tracing::info!("picker closed by the compositor");
self.outcome.get_or_insert(None);
}
}
pub fn frame_done(&mut self, surface: &WlSurface, qh: &QueueHandle<State>) {
if self.is(surface) {
self.draw(qh);
}
}
fn draw(&mut self, qh: &QueueHandle<State>) {
let (Some((w, h)), true) = (self.size, self.paint.is_some()) else {
return;
};
if self.overlay_dirty {
let px = (
(w as f64 * self.scale).round() as u32,
(h as f64 * self.scale).round() as u32,
);
let overlay = self.overlay(px);
if let Some(paint) = &mut self.paint {
paint.set_overlay(overlay.as_ref());
}
self.overlay_dirty = false;
}
let now = Instant::now();
let frame = Frame {
time: (now - self.start).as_secs_f32(),
motion: self.motion,
speed: SPEED,
scale: 1.0,
palette: self.palette(now),
};
let surface = self.layer.wl_surface();
surface.frame(qh, FrameCallbackData(surface.clone()));
if let Some(paint) = &self.paint
&& let Err(e) = paint.draw(&frame)
{
self.error = Some(e);
}
}
/// The cards, drawn at buffer size `px`.
fn overlay(&self, px: (u32, u32)) -> Option<Pixmap> {
let mut p = Pixmap::new(px.0, px.1)?;
let s = self.scale as f32;
let highlight = self
.palette(Instant::now() + EASE)
.highlight
.map(|c| (c * 255.0) as u8);
for (i, (card, r)) in self.cards.iter().zip(self.rects()).enumerate() {
let selected = i == self.selected;
let k = r.w / layout::CARD.0;
let panel = crate::toolbar::layout::Rect {
x: r.x,
y: r.y,
w: r.w,
h: r.h,
};
let border = if selected {
[highlight[0], highlight[1], highlight[2], 255]
} else {
[255, 255, 255, 40]
};
let bw = if selected { 2.0 } else { 1.0 };
crate::toolbar::draw::rounded(&mut p, panel, 18.0 * k, border, s);
let inner = crate::toolbar::layout::Rect {
x: r.x + bw,
y: r.y + bw,
w: r.w - 2.0 * bw,
h: r.h - 2.0 * bw,
};
crate::toolbar::draw::rounded(
&mut p,
inner,
18.0 * k - bw,
[0, 0, 0, if selected { 140 } else { 90 }],
s,
);
// The icon, centred near the top.
let icon = ICON * k;
let (ix, iy) = (r.x + (r.w - icon) / 2.0, r.y + 20.0 * k);
match &card.icon {
Some(img) => {
let t = Transform::from_scale(
icon * s / img.width() as f32,
icon * s / img.height() as f32,
)
.post_translate(ix * s, iy * s);
p.draw_pixmap(
0,
0,
img.as_ref(),
&PixmapPaint {
quality: tiny_skia::FilterQuality::Bilinear,
..Default::default()
},
t,
None,
);
}
None => {
let tile = crate::toolbar::layout::Rect {
x: ix,
y: iy,
w: icon,
h: icon,
};
let c = card.palette.body.map(|v| (v * 255.0) as u8);
crate::toolbar::draw::rounded(
&mut p,
tile,
20.0 * k,
[c[0], c[1], c[2], 255],
s,
);
let initial: String = card
.app
.chars()
.next()
.map(|c| c.to_uppercase().collect())
.unwrap_or_default();
centred(
&mut p,
&initial,
48.0 * k * s,
(ix + icon / 2.0) * s,
(iy + icon / 2.0) * s,
[255, 255, 255, 255],
);
}
}
let title = ellipsis(&card.title, 14.0 * k, r.w - 20.0 * k);
centred(
&mut p,
&title,
14.0 * k * s,
(r.x + r.w / 2.0) * s,
(iy + icon + 26.0 * k) * s,
[255, 255, 255, 255],
);
let app = ellipsis(&card.app, 12.0 * k, r.w - 20.0 * k);
centred(
&mut p,
&app,
12.0 * k * s,
(r.x + r.w / 2.0) * s,
(iy + icon + 48.0 * k) * s,
[255, 255, 255, 170],
);
}
Some(p)
}
pub fn pointer(&mut self, events: &[PointerEvent], cursor: Option<&WpCursorShapeDeviceV1>) {
for event in events {
if !self.is(&event.surface) {
continue;
}
let p = (event.position.0 as f32, event.position.1 as f32);
match event.kind {
PointerEventKind::Enter { serial } => {
if let Some(cursor) = cursor {
cursor.set_shape(serial, Shape::Default);
}
}
PointerEventKind::Motion { .. } => {
if let Some(i) = layout::hit(&self.rects(), p) {
self.select(i);
}
}
PointerEventKind::Press {
button: BTN_LEFT, ..
} => {
let hit = layout::hit(&self.rects(), p);
tracing::info!("picker click at {p:?}: card {hit:?}");
self.outcome = Some(hit);
}
_ => {}
}
}
}
pub fn key(&mut self, key: Keysym) {
let n = self.cards.len();
match key {
Keysym::Escape => {
tracing::info!("picker: Esc");
self.outcome = Some(None);
}
Keysym::Return | Keysym::KP_Enter => self.outcome = Some(Some(self.selected)),
Keysym::Tab if self.shift => self.select(layout::step(self.selected, n, -1)),
Keysym::ISO_Left_Tab | Keysym::Left | Keysym::Up => {
self.select(layout::step(self.selected, n, -1))
}
Keysym::Tab | Keysym::Right | Keysym::Down => {
self.select(layout::step(self.selected, n, 1))
}
_ => {}
}
}
pub fn modifiers(&mut self, m: Modifiers) {
self.shift = m.shift;
}
}
/// `s` shortened with "…" to fit `width` logical px at `size`.
fn ellipsis(s: &str, size: f32, width: f32) -> String {
if text::layout(s, size).width <= width {
return s.to_string();
}
let mut chars: Vec<char> = s.chars().collect();
while !chars.is_empty() {
chars.pop();
let t: String = chars.iter().collect::<String>() + "…";
if text::layout(&t, size).width <= width {
return t;
}
}
String::new()
}
/// `s` centred on (`cx`, `cy`), in buffer px.
fn centred(p: &mut Pixmap, s: &str, size: f32, cx: f32, cy: f32, color: [u8; 4]) {
let l = text::layout(s, size);
let origin = (cx - l.width / 2.0, cy - l.height / 2.0);
if let Some(path) = text::path(s, size, origin) {
let mut paint = SkPaint::default();
paint.set_color_rgba8(color[0], color[1], color[2], color[3]);
paint.anti_alias = true;
p.fill_path(
&path,
&paint,
FillRule::Winding,
Transform::identity(),
None,
);
}
}
-3
View File
@@ -48,8 +48,6 @@ struct Uniforms {
pub struct Paint {
gl: glow::Context,
u: Uniforms,
// Read by set_overlay, which the picker (theme plan Task 6) starts using.
#[allow(dead_code)]
overlay: glow::Texture,
size: (u32, u32),
egl: Egl,
@@ -130,7 +128,6 @@ impl Paint {
/// Shows `pixmap` (premultiplied RGBA, the surface's size) over the
/// paint, or nothing.
#[allow(dead_code)] // the picker (theme plan Task 6) removes this
pub fn set_overlay(&mut self, pixmap: Option<&tiny_skia::Pixmap>) {
if self.current().is_err() {
return;
+1 -1
View File
@@ -26,7 +26,7 @@ fn paint(c: [u8; 4]) -> Paint<'static> {
p
}
fn rounded(pixmap: &mut Pixmap, r: Rect, radius: f32, color: [u8; 4], scale: f32) {
pub(crate) fn rounded(pixmap: &mut Pixmap, r: Rect, radius: f32, color: [u8; 4], scale: f32) {
let (x, y, w, h, k) = (
r.x * scale,
r.y * scale,
+22
View File
@@ -42,6 +42,7 @@ use crate::dnd::Outcome;
use crate::error::HintExt;
use crate::frame::OutputGeom;
use crate::host::Host;
use crate::picker::Picker;
use crate::region::Overlay;
use crate::toolbar::{Pill, Toolbar};
use crate::zoom::Zoom;
@@ -90,6 +91,8 @@ pub struct State {
pub pill: Option<Pill>,
/// `valw backdrop`'s surfaces.
pub backdrop: Option<Backdrop>,
/// `valw window`'s picker, while it is open.
pub picker: Option<Picker>,
/// The preview thumbnails, in the preview host process.
pub preview: Option<Host>,
}
@@ -125,6 +128,7 @@ impl Wayland {
toolbar: None,
pill: None,
backdrop: None,
picker: None,
preview: None,
};
// Two round trips: one for wl_output, one for the xdg-output details.
@@ -317,6 +321,9 @@ impl CompositorHandler for State {
if let Some(backdrop) = &mut self.backdrop {
backdrop.frame_done(surface, qh);
}
if let Some(picker) = &mut self.picker {
picker.frame_done(surface, qh);
}
if let Some(preview) = &mut self.preview {
preview.frame_done(surface, qh);
}
@@ -383,6 +390,9 @@ impl LayerShellHandler for State {
if let Some(backdrop) = &mut self.backdrop {
backdrop.closed(layer);
}
if let Some(picker) = &mut self.picker {
picker.closed(layer);
}
if let Some(preview) = &mut self.preview {
preview.closed(layer);
}
@@ -411,6 +421,9 @@ impl LayerShellHandler for State {
if let Some(backdrop) = &mut self.backdrop {
backdrop.configure(conn, layer, configure.new_size, qh);
}
if let Some(picker) = &mut self.picker {
picker.configure(conn, layer, configure.new_size, qh);
}
if let Some(preview) = &mut self.preview {
preview.configure(layer, qh);
}
@@ -519,6 +532,9 @@ impl KeyboardHandler for State {
if let Some(toolbar) = &mut self.toolbar {
toolbar.key(event.keysym);
}
if let Some(picker) = &mut self.picker {
picker.key(event.keysym);
}
}
fn repeat_key(
@@ -562,6 +578,9 @@ impl KeyboardHandler for State {
if let Some(overlay) = &mut self.overlay {
overlay.modifiers(modifiers, qh);
}
if let Some(picker) = &mut self.picker {
picker.modifiers(modifiers);
}
}
}
@@ -585,6 +604,9 @@ impl PointerHandler for State {
if let Some(pill) = &mut self.pill {
pill.pointer(events);
}
if let Some(picker) = &mut self.picker {
picker.pointer(events, self.cursor_device.as_ref());
}
if self.preview.is_some() {
crate::host::pointer(self, events, qh);
}
+9 -1
View File
@@ -16,7 +16,15 @@ const TIMEOUT: Duration = Duration::from_secs(5);
/// on, if niri knows. Esc during the pick is `Cancelled`.
pub fn capture(cursor: bool) -> Result<(RgbaImage, Option<String>)> {
let window = niri::pick_window()?.ok_or(Cancelled)?;
tracing::info!("picked window {} ({:?})", window.id, window.app_id);
capture_window(&window, cursor)
}
/// Captures `window` (chosen by niri's pick or by valw's picker).
pub fn capture_window(
window: &niri_ipc::Window,
cursor: bool,
) -> Result<(RgbaImage, Option<String>)> {
tracing::info!("capturing window {} ({:?})", window.id, window.app_id);
let tmp = TempFile(temp_path());
niri::screenshot_window(window.id, &tmp.0, cursor, TIMEOUT)?;
let image = crate::thumbnail::load(&tmp.0)?;