feat: valw toolbar
This commit is contained in:
1 parent
a1ba80f15e
commit
d4b4fe0685
3 files changed
+470
-5
No files matched your search
+56
-4
@@ -1,6 +1,3 @@
|
||||
// The toolbar lands in pieces; Task 3 removes this.
|
||||
#![allow(dead_code)]
|
||||
|
||||
mod capture;
|
||||
mod config;
|
||||
mod detach;
|
||||
@@ -78,6 +75,8 @@ enum Command {
|
||||
/// The image to edit.
|
||||
file: PathBuf,
|
||||
},
|
||||
/// Pick a mode from a floating bar (Cmd+Shift+5).
|
||||
Toolbar,
|
||||
/// Report what the compositor and system support.
|
||||
Doctor,
|
||||
/// Internal: the process that shows preview thumbnails.
|
||||
@@ -157,6 +156,11 @@ fn run(cli: Cli) -> Result<()> {
|
||||
Command::Window { common } => capture(Mode::Window, common),
|
||||
Command::Zoom { common } => capture(Mode::Zoom, common),
|
||||
Command::Edit { file } => editor::run(&file),
|
||||
Command::Toolbar => {
|
||||
let config = config::load(&config::default_path())?;
|
||||
let (mode, common) = from_toolbar(toolbar::run(&config)?);
|
||||
capture(mode, common)
|
||||
}
|
||||
Command::Clipboard => {
|
||||
let mut png = Vec::new();
|
||||
std::io::Read::read_to_end(&mut std::io::stdin(), &mut png)
|
||||
@@ -260,8 +264,26 @@ fn window_shot(outputs: &[wayland::Output], cursor: bool) -> Result<Shots> {
|
||||
Ok((vec![(image, None)], 0, source))
|
||||
}
|
||||
|
||||
/// What the toolbar picked, as the command line would have said it.
|
||||
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::Region => Mode::Region,
|
||||
toolbar::state::Mode::Zoom => Mode::Zoom,
|
||||
};
|
||||
let common = Common {
|
||||
clipboard_only: false,
|
||||
output: None,
|
||||
delay: None,
|
||||
cursor: pick.cursor,
|
||||
no_preview: !pick.preview,
|
||||
};
|
||||
(mode, common)
|
||||
}
|
||||
|
||||
/// Index of niri's focused output, or 0 if niri can't tell us.
|
||||
fn focused_output(outputs: &[wayland::Output]) -> usize {
|
||||
pub(crate) fn focused_output(outputs: &[wayland::Output]) -> usize {
|
||||
let name = match niri::focused_output() {
|
||||
Ok(Some(name)) => name,
|
||||
Ok(None) => return 0,
|
||||
@@ -356,6 +378,36 @@ mod tests {
|
||||
assert!(parses(&["edit", "a.png"]));
|
||||
assert!(!parses(&["edit"]));
|
||||
assert!(parses(&["__clipboard"]));
|
||||
assert!(parses(&["toolbar"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_toolbar_pick_becomes_a_capture() {
|
||||
use toolbar::state::Mode as T;
|
||||
let pick = |mode, cursor, preview| toolbar::Picked {
|
||||
mode,
|
||||
cursor,
|
||||
preview,
|
||||
};
|
||||
let (mode, common) = from_toolbar(pick(T::Screen, true, false));
|
||||
assert!(matches!(mode, Mode::Screen { all: false }));
|
||||
assert!(common.cursor && common.no_preview);
|
||||
assert!(matches!(
|
||||
from_toolbar(pick(T::Window, false, true)).0,
|
||||
Mode::Window
|
||||
));
|
||||
assert!(matches!(
|
||||
from_toolbar(pick(T::Region, false, true)).0,
|
||||
Mode::Region
|
||||
));
|
||||
let (mode, common) = from_toolbar(pick(T::Zoom, false, true));
|
||||
assert!(matches!(mode, Mode::Zoom));
|
||||
assert!(
|
||||
!common.cursor
|
||||
&& !common.no_preview
|
||||
&& common.delay.is_none()
|
||||
&& common.output.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+387
-1
@@ -1,5 +1,391 @@
|
||||
//! `valw toolbar`: a floating bar to pick a mode.
|
||||
//! `valw toolbar`: a floating bar to pick a mode, then the optional
|
||||
//! countdown. The choice runs through the normal capture.
|
||||
|
||||
pub mod draw;
|
||||
pub mod layout;
|
||||
pub mod state;
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use smithay_client_toolkit::{
|
||||
seat::{
|
||||
keyboard::Keysym,
|
||||
pointer::{BTN_LEFT, PointerEvent, PointerEventKind},
|
||||
},
|
||||
shell::{
|
||||
WaylandSurface,
|
||||
wlr_layer::{Anchor, KeyboardInteractivity, Layer, LayerSurface},
|
||||
},
|
||||
shm::slot::{Buffer, SlotPool},
|
||||
};
|
||||
use wayland_client::protocol::wl_shm;
|
||||
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::{Layout, MenuItem, Target};
|
||||
use self::state::{Mode, ToolbarState};
|
||||
use crate::config::Config;
|
||||
use crate::error::{Cancelled, HintExt};
|
||||
use crate::wayland::{Output, Wayland};
|
||||
|
||||
pub const NAMESPACE: &str = "valw-toolbar";
|
||||
pub const COUNTDOWN_NAMESPACE: &str = "valw-countdown";
|
||||
const PILL: (f32, f32) = (72.0, 40.0);
|
||||
|
||||
/// The mode and options the user picked.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Picked {
|
||||
pub mode: Mode,
|
||||
pub cursor: bool,
|
||||
pub preview: bool,
|
||||
}
|
||||
|
||||
/// Shows the bar, waits for a pick, saves it, runs the countdown.
|
||||
pub fn run(config: &Config) -> Result<Picked> {
|
||||
let path = state::default_path();
|
||||
let mut wl = Wayland::connect()?;
|
||||
let outputs = wl.outputs();
|
||||
anyhow::ensure!(!outputs.is_empty(), "the compositor reported no outputs");
|
||||
let output = outputs[crate::focused_output(&outputs)].clone();
|
||||
let initial = state::load(&path, config);
|
||||
|
||||
let picked = pick(&mut wl, &output, initial)?;
|
||||
if let Err(e) = state::save(&path, &picked.0) {
|
||||
tracing::warn!("could not remember the toolbar choice: {e:#}");
|
||||
}
|
||||
countdown(&mut wl, &output, picked.0.timer)?;
|
||||
let s = picked.0;
|
||||
Ok(Picked {
|
||||
mode: picked.1,
|
||||
cursor: s.cursor,
|
||||
preview: s.preview,
|
||||
})
|
||||
}
|
||||
|
||||
pub struct Toolbar {
|
||||
layer: LayerSurface,
|
||||
viewport: WpViewport,
|
||||
pool: SlotPool,
|
||||
buffer: Option<Buffer>,
|
||||
scale: f64,
|
||||
/// Logical size once configured.
|
||||
size: Option<(u32, u32)>,
|
||||
layout: Option<Layout>,
|
||||
state: ToolbarState,
|
||||
hover: Option<Target>,
|
||||
menu_open: bool,
|
||||
outcome: Option<Option<Mode>>,
|
||||
}
|
||||
|
||||
fn pick(wl: &mut Wayland, output: &Output, initial: ToolbarState) -> Result<(ToolbarState, Mode)> {
|
||||
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 pool = SlotPool::new(4096, &s.shm).context("could not create shm pool")?;
|
||||
wl.state.toolbar = Some(Toolbar {
|
||||
layer,
|
||||
viewport,
|
||||
pool,
|
||||
buffer: None,
|
||||
scale: output.scale,
|
||||
size: None,
|
||||
layout: None,
|
||||
state: initial,
|
||||
hover: None,
|
||||
menu_open: false,
|
||||
outcome: None,
|
||||
});
|
||||
let result = wl.dispatch_blocking(|s| s.toolbar.as_ref().is_some_and(|t| t.outcome.is_some()));
|
||||
let toolbar = wl.state.toolbar.take();
|
||||
// The bar must be gone before anything is captured.
|
||||
let _ = wl.conn.flush();
|
||||
let _ = wl.queue.roundtrip(&mut wl.state);
|
||||
result?;
|
||||
let toolbar = toolbar.context("the toolbar vanished")?;
|
||||
match toolbar.outcome.flatten() {
|
||||
Some(mode) => Ok((
|
||||
ToolbarState {
|
||||
mode,
|
||||
..toolbar.state
|
||||
},
|
||||
mode,
|
||||
)),
|
||||
None => Err(Cancelled.into()),
|
||||
}
|
||||
}
|
||||
|
||||
impl Toolbar {
|
||||
fn is(&self, surface: &wayland_client::protocol::wl_surface::WlSurface) -> bool {
|
||||
self.layer.wl_surface() == surface
|
||||
}
|
||||
|
||||
pub fn configure(&mut self, layer: &LayerSurface, (w, h): (u32, u32)) {
|
||||
if !self.is(layer.wl_surface()) || w == 0 || h == 0 {
|
||||
return;
|
||||
}
|
||||
self.size = Some((w, h));
|
||||
self.layout = Some(layout::layout((w as f32, h as f32), draw::measure));
|
||||
self.viewport.set_destination(w as i32, h as i32);
|
||||
self.draw();
|
||||
}
|
||||
|
||||
pub fn closed(&mut self, layer: &LayerSurface) {
|
||||
if self.is(layer.wl_surface()) {
|
||||
self.outcome.get_or_insert(None);
|
||||
}
|
||||
}
|
||||
|
||||
fn draw(&mut self) {
|
||||
let (Some((w, h)), Some(layout)) = (self.size, &self.layout) else {
|
||||
return;
|
||||
};
|
||||
let (pw, ph) = (
|
||||
(w as f64 * self.scale).round() as u32,
|
||||
(h as f64 * self.scale).round() as u32,
|
||||
);
|
||||
let pixmap = draw::bar(
|
||||
layout,
|
||||
(pw, ph),
|
||||
self.scale as f32,
|
||||
&self.state,
|
||||
self.hover,
|
||||
self.menu_open,
|
||||
);
|
||||
let Ok((buffer, canvas)) = self.pool.create_buffer(
|
||||
pw as i32,
|
||||
ph as i32,
|
||||
pw as i32 * 4,
|
||||
wl_shm::Format::Argb8888,
|
||||
) else {
|
||||
tracing::warn!("could not allocate a toolbar buffer");
|
||||
return;
|
||||
};
|
||||
draw::to_argb(&pixmap, canvas);
|
||||
let surface = self.layer.wl_surface();
|
||||
surface.damage_buffer(0, 0, pw as i32, ph as i32);
|
||||
if buffer.attach_to(surface).is_err() {
|
||||
tracing::warn!("could not attach the toolbar buffer");
|
||||
return;
|
||||
}
|
||||
self.layer.commit();
|
||||
self.buffer = Some(buffer);
|
||||
}
|
||||
|
||||
pub fn pointer(&mut self, events: &[PointerEvent], cursor: Option<&WpCursorShapeDeviceV1>) {
|
||||
let Some(layout) = self.layout.clone() else {
|
||||
return;
|
||||
};
|
||||
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 { .. } => {
|
||||
let hover = layout.hit(p, self.menu_open);
|
||||
if hover != self.hover {
|
||||
self.hover = hover;
|
||||
self.draw();
|
||||
}
|
||||
}
|
||||
PointerEventKind::Press {
|
||||
button: BTN_LEFT, ..
|
||||
} => match layout.hit(p, self.menu_open) {
|
||||
Some(Target::Mode(mode)) => self.outcome = Some(Some(mode)),
|
||||
Some(Target::Options) => {
|
||||
self.menu_open = !self.menu_open;
|
||||
self.draw();
|
||||
}
|
||||
Some(Target::Menu(item)) => {
|
||||
match item {
|
||||
MenuItem::Timer(t) => self.state.timer = t,
|
||||
MenuItem::Cursor => self.state.cursor = !self.state.cursor,
|
||||
MenuItem::Preview => self.state.preview = !self.state.preview,
|
||||
}
|
||||
self.draw();
|
||||
}
|
||||
None if !layout.inside(p, self.menu_open) => self.outcome = Some(None),
|
||||
None => {}
|
||||
},
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn key(&mut self, key: Keysym) {
|
||||
if key == Keysym::Escape {
|
||||
self.outcome.get_or_insert(None);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Pill {
|
||||
layer: LayerSurface,
|
||||
viewport: WpViewport,
|
||||
pool: SlotPool,
|
||||
buffer: Option<Buffer>,
|
||||
scale: f64,
|
||||
configured: bool,
|
||||
text: String,
|
||||
cancelled: bool,
|
||||
}
|
||||
|
||||
/// Shows `timer`, `timer - 1`, … 1, one second each; a click on the pill
|
||||
/// cancels. The pill takes no keyboard focus, so the desktop stays usable.
|
||||
fn countdown(wl: &mut Wayland, output: &Output, timer: u32) -> Result<()> {
|
||||
if timer == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
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")?;
|
||||
let viewporter = s
|
||||
.viewporter
|
||||
.as_ref()
|
||||
.context("compositor does not support wp-viewporter")?;
|
||||
let surface = s.compositor.create_surface(&qh);
|
||||
let layer = layer_shell.create_layer_surface(
|
||||
&qh,
|
||||
surface,
|
||||
Layer::Overlay,
|
||||
Some(COUNTDOWN_NAMESPACE),
|
||||
Some(&output.wl),
|
||||
);
|
||||
layer.set_anchor(Anchor::BOTTOM);
|
||||
layer.set_margin(0, 0, layout::MARGIN_BOTTOM as i32, 0);
|
||||
layer.set_size(PILL.0 as u32, PILL.1 as u32);
|
||||
layer.set_keyboard_interactivity(KeyboardInteractivity::None);
|
||||
layer.commit();
|
||||
let viewport = viewporter.get_viewport(layer.wl_surface(), &qh, ());
|
||||
let pool = SlotPool::new(4096, &s.shm).context("could not create shm pool")?;
|
||||
wl.state.pill = Some(Pill {
|
||||
layer,
|
||||
viewport,
|
||||
pool,
|
||||
buffer: None,
|
||||
scale: output.scale,
|
||||
configured: false,
|
||||
text: timer.to_string(),
|
||||
cancelled: false,
|
||||
});
|
||||
|
||||
let start = Instant::now();
|
||||
let mut result = Ok(());
|
||||
for left in (1..=timer).rev() {
|
||||
if let Some(pill) = &mut wl.state.pill {
|
||||
pill.set_text(&left.to_string());
|
||||
}
|
||||
let until = start + Duration::from_secs((timer - left + 1) as u64);
|
||||
// dispatch_until fails when the deadline passes: that is the tick.
|
||||
let _ = wl.dispatch_until(until, |s| s.pill.as_ref().is_some_and(|p| p.cancelled));
|
||||
if wl.state.pill.as_ref().is_some_and(|p| p.cancelled) {
|
||||
result = Err(Cancelled.into());
|
||||
break;
|
||||
}
|
||||
}
|
||||
wl.state.pill = None;
|
||||
// The pill must be gone before anything is captured.
|
||||
let _ = wl.conn.flush();
|
||||
let _ = wl.queue.roundtrip(&mut wl.state);
|
||||
result
|
||||
}
|
||||
|
||||
impl Pill {
|
||||
fn is(&self, surface: &wayland_client::protocol::wl_surface::WlSurface) -> bool {
|
||||
self.layer.wl_surface() == surface
|
||||
}
|
||||
|
||||
pub fn configure(&mut self, layer: &LayerSurface) {
|
||||
if self.is(layer.wl_surface()) {
|
||||
self.configured = true;
|
||||
self.viewport.set_destination(PILL.0 as i32, PILL.1 as i32);
|
||||
self.draw();
|
||||
}
|
||||
}
|
||||
|
||||
fn set_text(&mut self, text: &str) {
|
||||
self.text = text.to_string();
|
||||
self.draw();
|
||||
}
|
||||
|
||||
fn draw(&mut self) {
|
||||
if !self.configured {
|
||||
return;
|
||||
}
|
||||
let (pw, ph) = (
|
||||
(PILL.0 as f64 * self.scale).round() as u32,
|
||||
(PILL.1 as f64 * self.scale).round() as u32,
|
||||
);
|
||||
let pixmap = draw::pill((pw, ph), self.scale as f32, &self.text);
|
||||
let Ok((buffer, canvas)) = self.pool.create_buffer(
|
||||
pw as i32,
|
||||
ph as i32,
|
||||
pw as i32 * 4,
|
||||
wl_shm::Format::Argb8888,
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
draw::to_argb(&pixmap, canvas);
|
||||
let surface = self.layer.wl_surface();
|
||||
surface.damage_buffer(0, 0, pw as i32, ph as i32);
|
||||
if buffer.attach_to(surface).is_ok() {
|
||||
self.layer.commit();
|
||||
self.buffer = Some(buffer);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn pointer(&mut self, events: &[PointerEvent]) {
|
||||
for event in events {
|
||||
if self.is(&event.surface)
|
||||
&& matches!(
|
||||
event.kind,
|
||||
PointerEventKind::Press {
|
||||
button: BTN_LEFT,
|
||||
..
|
||||
}
|
||||
)
|
||||
{
|
||||
self.cancelled = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn closed(&mut self, layer: &LayerSurface) {
|
||||
if self.is(layer.wl_surface()) {
|
||||
self.cancelled = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,7 @@ use crate::error::HintExt;
|
||||
use crate::frame::OutputGeom;
|
||||
use crate::host::Host;
|
||||
use crate::region::Overlay;
|
||||
use crate::toolbar::{Pill, Toolbar};
|
||||
use crate::zoom::Zoom;
|
||||
|
||||
/// An output as valw sees it.
|
||||
@@ -83,6 +84,9 @@ pub struct State {
|
||||
pub overlay: Option<Overlay>,
|
||||
/// Zoom mode, while it is open.
|
||||
pub zoom: Option<Zoom>,
|
||||
/// The toolbar and its countdown, while they are open.
|
||||
pub toolbar: Option<Toolbar>,
|
||||
pub pill: Option<Pill>,
|
||||
/// The preview thumbnails, in the preview host process.
|
||||
pub preview: Option<Host>,
|
||||
}
|
||||
@@ -115,6 +119,8 @@ impl Wayland {
|
||||
captures: Vec::new(),
|
||||
overlay: None,
|
||||
zoom: None,
|
||||
toolbar: None,
|
||||
pill: None,
|
||||
preview: None,
|
||||
};
|
||||
// Two round trips: one for wl_output, one for the xdg-output details.
|
||||
@@ -348,6 +354,12 @@ impl LayerShellHandler for State {
|
||||
if let Some(zoom) = &mut self.zoom {
|
||||
zoom.closed(layer);
|
||||
}
|
||||
if let Some(toolbar) = &mut self.toolbar {
|
||||
toolbar.closed(layer);
|
||||
}
|
||||
if let Some(pill) = &mut self.pill {
|
||||
pill.closed(layer);
|
||||
}
|
||||
if let Some(preview) = &mut self.preview {
|
||||
preview.closed(layer);
|
||||
}
|
||||
@@ -367,6 +379,12 @@ impl LayerShellHandler for State {
|
||||
if let Some(zoom) = &mut self.zoom {
|
||||
zoom.configure(layer, configure.new_size, qh);
|
||||
}
|
||||
if let Some(toolbar) = &mut self.toolbar {
|
||||
toolbar.configure(layer, configure.new_size);
|
||||
}
|
||||
if let Some(pill) = &mut self.pill {
|
||||
pill.configure(layer);
|
||||
}
|
||||
if let Some(preview) = &mut self.preview {
|
||||
preview.configure(layer, qh);
|
||||
}
|
||||
@@ -472,6 +490,9 @@ impl KeyboardHandler for State {
|
||||
if let Some(zoom) = &mut self.zoom {
|
||||
zoom.key(event.keysym, qh);
|
||||
}
|
||||
if let Some(toolbar) = &mut self.toolbar {
|
||||
toolbar.key(event.keysym);
|
||||
}
|
||||
}
|
||||
|
||||
fn repeat_key(
|
||||
@@ -524,6 +545,12 @@ impl PointerHandler for State {
|
||||
if let Some(zoom) = &mut self.zoom {
|
||||
zoom.pointer(events, self.cursor_device.as_ref(), qh);
|
||||
}
|
||||
if let Some(toolbar) = &mut self.toolbar {
|
||||
toolbar.pointer(events, self.cursor_device.as_ref());
|
||||
}
|
||||
if let Some(pill) = &mut self.pill {
|
||||
pill.pointer(events);
|
||||
}
|
||||
if self.preview.is_some() {
|
||||
crate::host::pointer(self, events, qh);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user