This commit is contained in:
entailz 2026-04-09 14:04:52 -07:00
commit 1a50f2bf11
23 changed files with 13058 additions and 0 deletions

1
.gitignore vendored Normal file
View file

@ -0,0 +1 @@
target/

5777
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

41
Cargo.toml Normal file
View file

@ -0,0 +1,41 @@
[workspace]
[package]
name = "blast"
version = "0.1.0"
edition = "2021"
description = "Hyprland screenshot tool"
[[bin]]
name = "blast"
path = "src/main.rs"
[[bin]]
name = "blast-gui"
path = "src/gui/main.rs"
[dependencies]
ab_glyph = "0.2"
clap = { version = "4", features = ["derive"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
wl-clipboard-rs = "0.9.3"
notify-rust = "4"
wayland-client = "0.31"
wayland-protocols-wlr = { version = "0.2", features = ["client"] }
wayland-protocols = { version = "0.31", features = ["client", "staging", "unstable"] }
wayland-scanner = "0.31"
wayland-backend = "0.3"
image = { version = "0.25", features = ["png"] }
png = "0.17"
dirs = "5"
rand = "0.8"
thiserror = "1"
anyhow = "1"
libc = "0.2"
tempfile = "3"
gbm = "0.18"
memmap2 = "0.9"
eframe = { version = "0.29", features = ["wayland"] }
egui = "0.29"
egui_extras = { version = "0.29", features = ["image"] }

98
README.md Normal file
View file

@ -0,0 +1,98 @@
## Blast
A pretty Wayland screenshot tool supporting multiple capture modes, including window-aware capture.
│ Note
│ This tool includes a proof-of-concept implementation of the
│ xx-foreign-toplevel-geometry-v1 protocol for tracking client geometry.
│ It is experimental and may be incomplete or subject to change.
## Supported Compositors
- Hyprland
- Wry
## Features
### Actions
- **`copy`** - Copy to clipboard
- **`save`** - Save to file (or - for stdout)
- **`copysave`** - Copy and save, save path defaults to home directory
- **`edit`** - Open in image editor
- **`check`** - Verify protocol support
### Subjects
- **`screen`** - All monitors stitched into a single image at logical resolution
- **`output`** - The currently focused monitor
- **`active`** - The currently focused window
- **`area`** - Interactive selection with window snap hints (like `slurp -w`)
- **`region`** - Free-form interactive selection with no snap hints
### Fine Tune
```
--no-shadow Disable shadow and rounding entirely
--radius px Corner radius
--shadow-offset X,Y Shadow offset
--shadow-alpha 0-255 Shadow opacity
--shadow-spread PX Shadow spread distance
--shadow-blur PX Shadow blur radius
```
### Freeze overlay
`-f / --freeze` captures the current screen state before the selection UI appears and renders it as a static layer shell overlay. The screen appears frozen while you draw your selection and is captured directly from the compositor.
The screenshot is taken while the freeze is still active, so what you see is exactly what you get.
### Cursor inclusion
`-c / --cursor` includes the cursor in the screenshot.
### Wait
`-w / --wait N` waits N seconds before capturing. Useful for capturing tooltips or hover states.
### Notifications
`-n / --notify` sends a desktop notification on completion with the saved file path as the notification body.
### Editor integration
`blast edit` saves the screenshot to `/tmp/<timestamp>.png` and opens it in your configured editor. Set `$BLAST_EDITOR` to override the default (`gimp`).
### Save path resolution
Output paths follow XDG priority: `$XDG_SCREENSHOTS_DIR``$XDG_PICTURES_DIR``$HOME`. Default filenames are `<window-class>_<random>.png`.
---
## Usage examples
```sh
# Copy a window-snapping region selection to clipboard, with freeze and shadow
blast -fn copy area
# Save the active window to ~/Pictures
blast save active
# Copy full screen of all monitors to clipboard
blast copy screen
# Save a free-form selection to a specific path
blast save region ~/screenshots/test.png
# Copy to clipboard and save, no shadow
blast --no-shadow copysave area
# Open focused monitor screenshot in editor
blast edit output
```
## Building
```sh
cargo build --release
```

View file

@ -0,0 +1,120 @@
<?xml version="1.0" encoding="UTF-8"?>
<protocol name="xx_foreign_toplevel_geometry_tracking_v1">
<copyright>
Copyright © 2026 outfoxxed
Permission to use, copy, modify, distribute, and sell this
software and its documentation for any purpose is hereby granted
without fee, provided that the above copyright notice appear in
all copies and that both that copyright notice and this permission
notice appear in supporting documentation, and that the name of
the copyright holders not be used in advertising or publicity
pertaining to distribution of the software without specific,
written prior permission. The copyright holders make no
representations about the suitability of this software for any
purpose. It is provided "as is" without express or implied
warranty.
THE COPYRIGHT HOLDERS DISCLAIM ALL WARRANTIES WITH REGARD TO THIS
SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND
FITNESS, IN NO EVENT SHALL THE COPYRIGHT HOLDERS BE LIABLE FOR ANY
SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN
AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION,
ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF
THIS SOFTWARE.
</copyright>
<description summary="protocol to track the geometry of foreign toplevels">
This protocol allows clients to track the geometry of toplevels relative
to all relevant outputs.
This protocol is privileged and should be limited to trusted clients.
The key words "must", "must not", "required", "shall", "shall not",
"should", "should not", "recommended", "may", and "optional" in this
document are to be interpreted as described in IETF RFC 2119.
</description>
<interface name="xx_foreign_toplevel_geometry_tracking_manager_v1" version="1">
<description summary="manager for foreign toplevel geometry trackers">
This global can be used to create foreign toplevel geometry trackers.
</description>
<request name="destroy" type="destructor">
<description summary="destroy the manager">
Destroy the manager. Any objects created by the manager are not affected.
</description>
</request>
<request name="get_geometry_tracker">
<description summary="create a toplevel geometry tracker">
Creates a toplevel geometry tracker for the given foreign toplevel handle.
</description>
<arg name="tracker" type="new_id" interface="xx_foreign_toplevel_geometry_tracker_v1"/>
<arg name="toplevel" type="object" interface="ext_foreign_toplevel_handle_v1"/>
</request>
</interface>
<interface name="xx_foreign_toplevel_geometry_tracker_v1" version="1">
<description summary="tracks geometry of a foreign toplevel relative to outputs">
This object tracks the geometry of a foreign toplevel relative to all outputs
it is present on.
Upon creation, a set of 'geometry' events must be sent. Additional event sets
should be sent whenever the toplevel is moved.
</description>
<request name="destroy" type="destructor">
<description summary="destroy the tracker object">
This request destroys the geometry tracker object.
</description>
</request>
<event name="finished">
<description summary="the compositor has finished with this tracker">
This event indicates no more geometry events will be sent for this tracker.
The client should destroy the tracker.
</description>
</event>
<event name="done">
<description summary="end of a toplevel geometry event list">
This event marks the end of a set of 'geometry' events for a toplevel.
The event set may be empty, indicating the toplevel is not present on
any output.
Following this event, 'ext_foreign_toplevel_handle_v1.done' must be sent
to allow toplevel geometry updates to be used atomically with other foreign
toplevel state changes.
</description>
</event>
<event name="geometry">
<description summary="monitor relative geometry has changed">
This event reports the geometry of the toplevel relative to the output in the
output's hardware coordinate space, as reported by 'wl_output.mode'. Geometry
should not be clipped to the bounds of the output even if the output does not
fully contain the toplevel.
Clients may use the logical coordinate information reported by `xdg_output`
to derive a global position for a toplevel.
One event may be sent for each output the toplevel is present on, followed by
a single 'done' event.
A series of geometry events should be resent whenever the toplevel is moved,
followed by a 'done' event.
</description>
<arg name="output" type="uint" summary="global id of the output"/>
<arg name="x" type="int" summary="x coordinate in the output's hardware coordinate space"/>
<arg name="y" type="int" summary="y coordinate in the output's hardware coordinate space"/>
<arg name="width" type="uint" summary="width in the output's hardware coordinate space"/>
<arg name="height" type="uint" summary="height in the output's hardware coordinate space"/>
</event>
</interface>
</protocol>

1134
src/capture.rs Normal file

File diff suppressed because it is too large Load diff

16
src/clipboard.rs Normal file
View file

@ -0,0 +1,16 @@
use std::thread;
use wl_clipboard_rs::copy::{MimeType, Options, Source};
use crate::error::{BlastError, Result};
pub fn copy_png(png_bytes: Vec<u8>) -> Result<()> {
Options::new()
.copy(
Source::Bytes(png_bytes.into()),
MimeType::Specific("image/png".into()),
)
.map_err(|e| BlastError::Clipboard(format!("{e}")))?;
thread::sleep(std::time::Duration::from_millis(50));
Ok(())
}

35
src/error.rs Normal file
View file

@ -0,0 +1,35 @@
use thiserror::Error;
#[derive(Debug, Error)]
pub enum BlastError {
#[error("Hyprland IPC error: {0}")]
Hyprland(String),
#[error("Screenshot capture error: {0}")]
Capture(String),
#[error("Region selection cancelled")]
SelectionCancelled,
#[error("Region selection error: {0}")]
Selection(String),
#[error("Clipboard error: {0}")]
Clipboard(String),
#[error("Notification error: {0}")]
#[allow(dead_code)]
Notify(String),
#[error("Image processing error: {0}")]
Image(String),
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("{0}")]
#[allow(dead_code)]
Other(String),
}
pub type Result<T> = std::result::Result<T, BlastError>;

911
src/freeze.rs Normal file
View file

@ -0,0 +1,911 @@
//! 1. For every output, screencopy-capture one frame into a SHM buffer.
//! 2. Create a zwlr_layer_shell_v1 surface at the OVERLAY layer covering
//! the full output with exclusive input (so the compositor stops rendering
//! live content underneath).
//! 3. Paint the captured SHM pixels onto the layer surface's wl_buffer and
//! commit.
//! 4. When FreezeGuard::drop() is called, destroy all layer surfaces and
//! the compositor resumes normal rendering.
//!
//! With --freeze-dmabuf, step 1 allocates a GBM buffer object instead of a
//! SHM file. The compositor fills the DMA-buf directly and the same object is
//! attached to the layer surface, keeping everything on-GPU and avoiding the
//! 10-bit bit-packing misinterpretation that occurs with the SHM path.
use std::{
fs::File,
io::{Read, Seek, SeekFrom, Write},
os::unix::io::{AsFd, FromRawFd, IntoRawFd, OwnedFd},
};
use gbm::{BufferObjectFlags, Device as GbmDevice};
use wayland_client::{
delegate_noop,
globals::{registry_queue_init, GlobalListContents},
protocol::{wl_buffer, wl_compositor, wl_output, wl_registry, wl_shm, wl_shm_pool, wl_surface},
Connection, Dispatch, EventQueue, QueueHandle,
};
use wayland_protocols::wp::linux_dmabuf::zv1::client::{
zwp_linux_buffer_params_v1::{self, ZwpLinuxBufferParamsV1},
zwp_linux_dmabuf_v1::ZwpLinuxDmabufV1,
};
use wayland_protocols_wlr::{
layer_shell::v1::client::{
zwlr_layer_shell_v1::{self, ZwlrLayerShellV1},
zwlr_layer_surface_v1::{self, ZwlrLayerSurfaceV1},
},
screencopy::v1::client::{zwlr_screencopy_frame_v1, zwlr_screencopy_manager_v1},
};
use crate::error::{BlastError, Result};
pub struct FreezeGuard {
inner: Option<FreezeInner>,
conn: Option<Connection>,
}
struct FreezeInner {
_conn: Connection,
_layer_surfaces: Vec<ZwlrLayerSurfaceV1>,
_wl_surfaces: Vec<wl_surface::WlSurface>,
}
impl FreezeGuard {
pub fn spawn() -> Result<Self> {
match FreezeState::run() {
Ok((inner, conn)) => Ok(FreezeGuard {
inner: Some(inner),
conn,
}),
Err(e) => {
eprintln!("blast: freeze failed ({e}), continuing without freeze");
Ok(FreezeGuard {
inner: None,
conn: None,
})
}
}
}
pub fn none() -> Self {
FreezeGuard {
inner: None,
conn: None,
}
}
pub fn kill(&mut self) {
self.inner = None;
if let Some(conn) = self.conn.take() {
let _ = conn.flush();
}
}
}
impl Drop for FreezeGuard {
fn drop(&mut self) {
self.kill();
}
}
#[derive(Debug, PartialEq)]
enum FrameState {
Pending,
Ready,
Failed,
}
#[derive(Clone)]
struct BufferOffer {
format: wl_shm::Format,
width: u32,
height: u32,
stride: u32,
}
/// DRM fourcc format + dimensions from screencopy's linux_dmabuf event.
#[derive(Clone)]
struct DmabufOffer {
format: u32, // DRM fourcc
width: u32,
height: u32,
}
struct OutputCapture {
output: wl_output::WlOutput,
output_name: String,
buffer_offers: Vec<BufferOffer>,
dmabuf_offers: Vec<DmabufOffer>,
shm_file: Option<File>,
shm_pool: Option<wl_shm_pool::WlShmPool>,
buffer: Option<wl_buffer::WlBuffer>,
/// Keeps the GBM buffer object alive for the lifetime of the overlay surface.
/// The wl_buffer references the underlying DMA-buf fd; the BO must not drop
/// before the compositor is done with it.
_gbm_bo: Option<gbm::BufferObject<()>>,
frame_state: FrameState,
frame_width: u32,
frame_height: u32,
frame_stride: u32,
frame_format: wl_shm::Format,
}
// state machine
struct FreezeState {
conn: Connection,
qh: QueueHandle<FreezeState>,
queue: Option<EventQueue<FreezeState>>,
compositor: Option<wl_compositor::WlCompositor>,
shm: Option<wl_shm::WlShm>,
shm_formats: Vec<wl_shm::Format>,
layer_shell: Option<ZwlrLayerShellV1>,
screencopy_mgr: Option<zwlr_screencopy_manager_v1::ZwlrScreencopyManagerV1>,
/// Present when the compositor supports zwp_linux_dmabuf_v1.
linux_dmabuf: Option<ZwpLinuxDmabufV1>,
captures: Vec<OutputCapture>,
layer_surfaces: Vec<ZwlrLayerSurfaceV1>,
wl_surfaces: Vec<wl_surface::WlSurface>,
configured_count: usize,
}
impl FreezeState {
fn run() -> Result<(FreezeInner, Option<Connection>)> {
let conn = Connection::connect_to_env()
.map_err(|e| BlastError::Capture(format!("freeze: connect: {e}")))?;
let (globals, queue) = registry_queue_init::<FreezeState>(&conn)
.map_err(|e| BlastError::Capture(format!("freeze: registry: {e}")))?;
let qh = queue.handle();
let compositor: wl_compositor::WlCompositor = globals
.bind(&qh, 4..=6, ())
.map_err(|e| BlastError::Capture(format!("freeze: compositor: {e}")))?;
let shm: wl_shm::WlShm = globals
.bind(&qh, 1..=1, ())
.map_err(|e| BlastError::Capture(format!("freeze: shm: {e}")))?;
let layer_shell: ZwlrLayerShellV1 = globals
.bind(&qh, 1..=4, ())
.map_err(|e| BlastError::Capture(format!("freeze: layer_shell: {e}")))?;
let screencopy_mgr: zwlr_screencopy_manager_v1::ZwlrScreencopyManagerV1 = globals
.bind(&qh, 1..=3, ())
.map_err(|e| BlastError::Capture(format!("freeze: screencopy_mgr: {e}")))?;
// Always attempt dmabuf; falls back to SHM if unavailable.
let linux_dmabuf: Option<ZwpLinuxDmabufV1> = globals.bind(&qh, 2..=4, ()).ok();
let raw_outputs: Vec<wl_output::WlOutput> = globals
.contents()
.clone_list()
.iter()
.filter(|g| g.interface == "wl_output")
.map(|g| {
globals.registry().bind::<wl_output::WlOutput, _, _>(
g.name,
g.version.min(4),
&qh,
(),
)
})
.collect();
let captures: Vec<OutputCapture> = raw_outputs
.into_iter()
.map(|o| OutputCapture {
output: o,
output_name: String::new(),
buffer_offers: Vec::new(),
dmabuf_offers: Vec::new(),
shm_file: None,
shm_pool: None,
buffer: None,
_gbm_bo: None,
frame_state: FrameState::Pending,
frame_width: 0,
frame_height: 0,
frame_stride: 0,
frame_format: wl_shm::Format::Argb8888,
})
.collect();
let mut state = FreezeState {
conn,
qh,
queue: Some(queue),
compositor: Some(compositor),
shm: Some(shm),
shm_formats: Vec::new(),
layer_shell: Some(layer_shell),
screencopy_mgr: Some(screencopy_mgr),
linux_dmabuf,
captures,
layer_surfaces: Vec::new(),
wl_surfaces: Vec::new(),
configured_count: 0,
};
state.roundtrip()?;
for i in 0..state.captures.len() {
let output = state.captures[i].output.clone();
let _frame = state
.screencopy_mgr
.as_ref()
.unwrap()
.capture_output(0, &output, &state.qh, i);
}
state.dispatch_until_frames_ready()?;
state.create_overlay_surfaces()?;
state.roundtrip()?;
let kill_conn = Connection::connect_to_env().ok();
Ok((
FreezeInner {
_conn: state.conn,
_layer_surfaces: state.layer_surfaces,
_wl_surfaces: state.wl_surfaces,
},
kill_conn,
))
}
fn roundtrip(&mut self) -> Result<()> {
let mut q = self
.queue
.take()
.ok_or_else(|| BlastError::Capture("freeze: queue missing".into()))?;
let r = q
.roundtrip(self)
.map_err(|e| BlastError::Capture(format!("freeze: roundtrip: {e}")));
self.queue = Some(q);
r?;
Ok(())
}
fn dispatch_once(&mut self) -> Result<()> {
let mut q = self
.queue
.take()
.ok_or_else(|| BlastError::Capture("freeze: queue missing".into()))?;
let r = q
.blocking_dispatch(self)
.map_err(|e| BlastError::Capture(format!("freeze: dispatch: {e}")));
self.queue = Some(q);
r?;
Ok(())
}
fn dispatch_until_frames_ready(&mut self) -> Result<()> {
loop {
if self
.captures
.iter()
.all(|c| c.frame_state != FrameState::Pending)
{
break;
}
self.dispatch_once()?;
}
Ok(())
}
fn dispatch_until_configured(&mut self) -> Result<()> {
let expected = self
.captures
.iter()
.filter(|c| c.frame_state == FrameState::Ready)
.count();
while self.configured_count < expected {
self.dispatch_once()?;
}
Ok(())
}
fn alloc_shm(size: usize) -> Result<File> {
#[cfg(target_os = "linux")]
{
use std::ffi::CStr;
let fd = unsafe {
libc::memfd_create(
CStr::from_bytes_with_nul(b"blast-freeze\0")
.unwrap()
.as_ptr(),
libc::MFD_CLOEXEC,
)
};
if fd >= 0 {
let f = unsafe { File::from_raw_fd(fd) };
f.set_len(size as u64)
.map_err(|e| BlastError::Capture(format!("freeze: set_len: {e}")))?;
return Ok(f);
}
}
let f = tempfile::tempfile()
.map_err(|e| BlastError::Capture(format!("freeze: tempfile: {e}")))?;
f.set_len(size as u64)
.map_err(|e| BlastError::Capture(format!("freeze: set_len: {e}")))?;
Ok(f)
}
fn create_overlay_surfaces(&mut self) -> Result<()> {
use wayland_protocols_wlr::layer_shell::v1::client::{
zwlr_layer_shell_v1::Layer, zwlr_layer_surface_v1::Anchor,
};
let mons = crate::hyprland::monitors().unwrap_or_default();
for i in 0..self.captures.len() {
if self.captures[i].frame_state != FrameState::Ready {
continue;
}
let output = self.captures[i].output.clone();
let phys_w = self.captures[i].frame_width;
let phys_h = self.captures[i].frame_height;
let scale = mons
.iter()
.find(|m| m.name == self.captures[i].output_name)
.map(|m| m.scale)
.unwrap_or(1.0);
let logical_w = (phys_w as f64 / scale).round() as u32;
let logical_h = (phys_h as f64 / scale).round() as u32;
let wl_surf = self
.compositor
.as_ref()
.unwrap()
.create_surface(&self.qh, ());
let layer_surf = self.layer_shell.as_ref().unwrap().get_layer_surface(
&wl_surf,
Some(&output),
Layer::Overlay,
"blast-freeze".to_string(),
&self.qh,
i,
);
layer_surf.set_size(0, 0);
layer_surf.set_exclusive_zone(-1);
layer_surf.set_anchor(Anchor::Top | Anchor::Bottom | Anchor::Left | Anchor::Right);
layer_surf
.set_keyboard_interactivity(zwlr_layer_surface_v1::KeyboardInteractivity::None);
let int_scale = scale.ceil() as i32;
wl_surf.set_buffer_scale(int_scale);
// Empty input region so the cursor passes through without
// triggering compositor input processing (which causes lag).
let empty_region = self
.compositor
.as_ref()
.unwrap()
.create_region(&self.qh, ());
wl_surf.set_input_region(Some(&empty_region));
empty_region.destroy();
wl_surf.commit();
self.layer_surfaces.push(layer_surf);
self.wl_surfaces.push(wl_surf);
let _ = logical_w;
let _ = logical_h;
}
self.dispatch_until_configured()?;
let mut surf_idx = 0;
for i in 0..self.captures.len() {
if self.captures[i].frame_state != FrameState::Ready {
continue;
}
if let (Some(surf), Some(buf)) = (
self.wl_surfaces.get(surf_idx),
self.captures[i].buffer.as_ref(),
) {
surf.attach(Some(buf), 0, 0);
surf.damage_buffer(0, 0, i32::MAX, i32::MAX);
surf.commit();
}
surf_idx += 1;
}
Ok(())
}
}
// Format selection helpers
/// Pick the best 8-bit SHM format from screencopy offers, constrained to what
/// wl_shm advertised. Mirrors the priority list used in capture.rs.
fn pick_offer(offers: &[BufferOffer], shm_formats: &[wl_shm::Format]) -> Option<BufferOffer> {
const PREFERRED: &[wl_shm::Format] = &[
wl_shm::Format::Argb8888,
wl_shm::Format::Xrgb8888,
wl_shm::Format::Abgr8888,
wl_shm::Format::Xbgr8888,
wl_shm::Format::Rgba8888,
wl_shm::Format::Bgra8888,
];
for &fmt in PREFERRED {
if let Some(o) = offers
.iter()
.find(|o| o.format == fmt && shm_formats.contains(&o.format))
{
return Some(o.clone());
}
}
// Last resort: anything wl_shm supports (may still be 10-bit).
offers
.iter()
.find(|o| shm_formats.contains(&o.format))
.cloned()
}
fn is_10bit(fmt: wl_shm::Format) -> bool {
matches!(
fmt,
wl_shm::Format::Xrgb2101010
| wl_shm::Format::Argb2101010
| wl_shm::Format::Xbgr2101010
| wl_shm::Format::Abgr2101010
| wl_shm::Format::Rgbx1010102
| wl_shm::Format::Rgba1010102
| wl_shm::Format::Bgrx1010102
| wl_shm::Format::Bgra1010102
)
}
// DMA-buf path
/// Walk /dev/dri/renderD128..135 and return the first one we can open.
fn find_drm_render_node() -> Option<File> {
for n in 128..=135u32 {
if let Ok(f) = std::fs::OpenOptions::new()
.read(true)
.write(true)
.open(format!("/dev/dri/renderD{n}"))
{
return Some(f);
}
}
None
}
/// Allocate a GBM buffer object for the given DMA-buf offer, export its fd,
/// create a wl_buffer via zwp_linux_dmabuf_v1, and kick off the screencopy
/// copy. Stores the resulting wl_buffer and BO in captures[i].
///
/// Returns true on success. On failure prints a diagnostic and returns false
/// so the caller can fall back to the SHM path.
fn alloc_dmabuf_capture(
state: &mut FreezeState,
i: usize,
frame: &zwlr_screencopy_frame_v1::ZwlrScreencopyFrameV1,
qh: &QueueHandle<FreezeState>,
) -> bool {
let offer = match state.captures[i].dmabuf_offers.first().cloned() {
Some(o) => o,
None => {
eprintln!("blast: freeze: dmabuf: no linux_dmabuf offers received from compositor");
return false;
}
};
let linux_dmabuf = match state.linux_dmabuf.clone() {
Some(d) => d,
None => return false,
};
let drm_file = match find_drm_render_node() {
Some(f) => f,
None => {
eprintln!(
"blast: freeze: dmabuf: no DRM render node found under /dev/dri/renderD128-135"
);
return false;
}
};
let gbm = match GbmDevice::new(drm_file) {
Ok(d) => d,
Err(e) => {
eprintln!("blast: freeze: dmabuf: GBM device: {e}");
return false;
}
};
let fmt = match gbm::Format::try_from(offer.format) {
Ok(f) => f,
Err(_) => {
eprintln!(
"blast: freeze: dmabuf: unrecognised DRM fourcc 0x{:08x}",
offer.format
);
return false;
}
};
// RENDERING | SCANOUT: we need a buffer the compositor can both write into
// (screencopy) and display (layer surface attachment). LINEAR is intentionally
// omitted - drivers may not support it for a given format, causing EINVAL.
// Instead we read the actual modifier back from the allocated BO and forward
// it to zwp_linux_buffer_params_v1 so the compositor interprets the tiling
// correctly.
let bo = match gbm.create_buffer_object::<()>(
offer.width,
offer.height,
fmt,
BufferObjectFlags::RENDERING | BufferObjectFlags::SCANOUT,
) {
Ok(b) => b,
Err(e) => {
eprintln!("blast: freeze: dmabuf: GBM alloc: {e}");
return false;
}
};
let stride = bo.stride();
let modifier = u64::from(bo.modifier());
let modifier_hi = (modifier >> 32) as u32;
let modifier_lo = (modifier & 0xFFFF_FFFF) as u32;
let dma_fd: OwnedFd = match bo.fd() {
Ok(f) => f,
Err(e) => {
eprintln!("blast: freeze: dmabuf: GBM fd export: {e}");
return false;
}
};
let params = linux_dmabuf.create_params(qh, ());
params.add(dma_fd.as_fd(), 0, 0, stride, modifier_hi, modifier_lo);
// dma_fd drops here; the compositor has already dup'd it inside add().
let wl_buf = params.create_immed(
offer.width as i32,
offer.height as i32,
offer.format,
zwp_linux_buffer_params_v1::Flags::empty(),
qh,
(),
);
frame.copy(&wl_buf);
state.captures[i].buffer = Some(wl_buf);
state.captures[i].frame_width = offer.width;
state.captures[i].frame_height = offer.height;
state.captures[i].frame_stride = stride;
// _gbm_bo keeps the DMA-buf alive until the overlay surface is destroyed.
// The GBM Device is kept alive internally by the BO's Arc reference.
state.captures[i]._gbm_bo = Some(bo);
true
}
// SHM 10-bit fallback conversion
/// Called after screencopy Ready when a 10-bit SHM format was the only option.
/// Reads the raw data, converts each pixel to XRGB8888, and replaces the
/// capture's pool/buffer with a new 8-bit one safe for wl_surface attachment.
fn convert_capture_to_8bit(
state: &mut FreezeState,
i: usize,
qh: &QueueHandle<FreezeState>,
) -> std::result::Result<(), String> {
let w = state.captures[i].frame_width;
let h = state.captures[i].frame_height;
let stride = state.captures[i].frame_stride;
let fmt = state.captures[i].frame_format;
let file = state.captures[i].shm_file.as_mut().ok_or("no shm file")?;
file.seek(SeekFrom::Start(0)).map_err(|e| e.to_string())?;
let mut raw = vec![0u8; (stride * h) as usize];
file.read_exact(&mut raw).map_err(|e| e.to_string())?;
let out_stride = w * 4;
let mut out = vec![0u8; (out_stride * h) as usize];
for row in 0..h {
for col in 0..w {
let src = (row * stride + col * 4) as usize;
let pixel = u32::from_le_bytes([raw[src], raw[src + 1], raw[src + 2], raw[src + 3]]);
let (r, g, b) = match fmt {
// A/X[31:30] R[29:20] G[19:10] B[9:0]
wl_shm::Format::Xrgb2101010 | wl_shm::Format::Argb2101010 => (
((pixel >> 20) & 0x3FF) >> 2,
((pixel >> 10) & 0x3FF) >> 2,
(pixel & 0x3FF) >> 2,
),
// A/X[31:30] B[29:20] G[19:10] R[9:0]
wl_shm::Format::Xbgr2101010 | wl_shm::Format::Abgr2101010 => (
(pixel & 0x3FF) >> 2,
((pixel >> 10) & 0x3FF) >> 2,
((pixel >> 20) & 0x3FF) >> 2,
),
// R[31:22] G[21:12] B[11:2] A/X[1:0]
wl_shm::Format::Rgbx1010102 | wl_shm::Format::Rgba1010102 => (
((pixel >> 22) & 0x3FF) >> 2,
((pixel >> 12) & 0x3FF) >> 2,
((pixel >> 2) & 0x3FF) >> 2,
),
// B[31:22] G[21:12] R[11:2] A/X[1:0]
wl_shm::Format::Bgrx1010102 | wl_shm::Format::Bgra1010102 => (
((pixel >> 2) & 0x3FF) >> 2,
((pixel >> 12) & 0x3FF) >> 2,
((pixel >> 22) & 0x3FF) >> 2,
),
_ => (0, 0, 0),
};
let dst = (row * out_stride + col * 4) as usize;
// XRGB8888 memory layout: [B, G, R, X]
out[dst] = b as u8;
out[dst + 1] = g as u8;
out[dst + 2] = r as u8;
out[dst + 3] = 0xFF;
}
}
let new_size = out.len();
let mut new_file = FreezeState::alloc_shm(new_size).map_err(|e| e.to_string())?;
new_file.write_all(&out).map_err(|e| e.to_string())?;
let fd = new_file
.try_clone()
.map_err(|e| e.to_string())?
.into_raw_fd();
let new_pool = state.shm.as_ref().unwrap().create_pool(
unsafe { std::os::unix::io::BorrowedFd::borrow_raw(fd) },
new_size as i32,
qh,
(),
);
let new_buf = new_pool.create_buffer(
0,
w as i32,
h as i32,
out_stride as i32,
wl_shm::Format::Xrgb8888,
qh,
(),
);
state.captures[i].shm_file = Some(new_file);
state.captures[i].shm_pool = Some(new_pool);
state.captures[i].buffer = Some(new_buf);
state.captures[i].frame_stride = out_stride;
state.captures[i].frame_format = wl_shm::Format::Xrgb8888;
Ok(())
}
impl Dispatch<wl_registry::WlRegistry, GlobalListContents> for FreezeState {
fn event(
_: &mut Self,
_: &wl_registry::WlRegistry,
_: wl_registry::Event,
_: &GlobalListContents,
_: &Connection,
_: &QueueHandle<Self>,
) {
}
}
impl Dispatch<wl_output::WlOutput, ()> for FreezeState {
fn event(
state: &mut Self,
proxy: &wl_output::WlOutput,
event: wl_output::Event,
_: &(),
_: &Connection,
_: &QueueHandle<Self>,
) {
if let wl_output::Event::Name { name } = event {
if let Some(cap) = state.captures.iter_mut().find(|c| &c.output == proxy) {
cap.output_name = name;
}
}
}
}
impl Dispatch<wl_shm::WlShm, ()> for FreezeState {
fn event(
state: &mut Self,
_: &wl_shm::WlShm,
event: wl_shm::Event,
_: &(),
_: &Connection,
_: &QueueHandle<Self>,
) {
if let wl_shm::Event::Format { format } = event {
if let wayland_client::WEnum::Value(f) = format {
state.shm_formats.push(f);
}
}
}
}
impl Dispatch<zwlr_screencopy_frame_v1::ZwlrScreencopyFrameV1, usize> for FreezeState {
fn event(
state: &mut Self,
frame: &zwlr_screencopy_frame_v1::ZwlrScreencopyFrameV1,
event: zwlr_screencopy_frame_v1::Event,
idx: &usize,
_: &Connection,
qh: &QueueHandle<Self>,
) {
let i = *idx;
match event {
zwlr_screencopy_frame_v1::Event::Buffer {
format,
width,
height,
stride,
} => {
if let wayland_client::WEnum::Value(fmt) = format {
state.captures[i].buffer_offers.push(BufferOffer {
format: fmt,
width,
height,
stride,
});
}
}
zwlr_screencopy_frame_v1::Event::LinuxDmabuf {
format,
width,
height,
} => {
state.captures[i].dmabuf_offers.push(DmabufOffer {
format,
width,
height,
});
}
zwlr_screencopy_frame_v1::Event::BufferDone => {
// Always prefer DMA-buf; fall back to SHM on any failure.
let used_dmabuf =
state.linux_dmabuf.is_some() && alloc_dmabuf_capture(state, i, frame, qh);
if !used_dmabuf {
let offer =
match pick_offer(&state.captures[i].buffer_offers, &state.shm_formats) {
Some(o) => o,
None => {
eprintln!(
"blast: freeze: no supported shm format in offers: {:?}",
state.captures[i]
.buffer_offers
.iter()
.map(|o| o.format)
.collect::<Vec<_>>()
);
state.captures[i].frame_state = FrameState::Failed;
return;
}
};
let size = (offer.stride * offer.height) as usize;
let file = match FreezeState::alloc_shm(size) {
Ok(f) => f,
Err(e) => {
eprintln!("blast: freeze: shm alloc: {e}");
state.captures[i].frame_state = FrameState::Failed;
return;
}
};
let fd = match file.try_clone() {
Ok(f) => f.into_raw_fd(),
Err(e) => {
eprintln!("blast: freeze: fd clone: {e}");
state.captures[i].frame_state = FrameState::Failed;
return;
}
};
let pool = state.shm.as_ref().unwrap().create_pool(
unsafe { std::os::unix::io::BorrowedFd::borrow_raw(fd) },
size as i32,
qh,
(),
);
let buf = pool.create_buffer(
0,
offer.width as i32,
offer.height as i32,
offer.stride as i32,
offer.format,
qh,
(),
);
state.captures[i].shm_file = Some(file);
state.captures[i].shm_pool = Some(pool);
state.captures[i].buffer = Some(buf.clone());
state.captures[i].frame_width = offer.width;
state.captures[i].frame_height = offer.height;
state.captures[i].frame_stride = offer.stride;
state.captures[i].frame_format = offer.format;
frame.copy(&buf);
}
}
zwlr_screencopy_frame_v1::Event::Ready { .. } => {
// DMA-buf path: buffer is already on-GPU, no conversion needed.
// SHM path: convert 10-bit data to XRGB8888 if necessary.
let is_dmabuf = state.captures[i]._gbm_bo.is_some();
if !is_dmabuf && is_10bit(state.captures[i].frame_format) {
if let Err(e) = convert_capture_to_8bit(state, i, qh) {
eprintln!("blast: freeze: 10-bit conversion failed: {e}");
}
}
state.captures[i].frame_state = FrameState::Ready;
}
zwlr_screencopy_frame_v1::Event::Failed => {
state.captures[i].frame_state = FrameState::Failed;
}
_ => {}
}
}
}
impl Dispatch<ZwlrLayerSurfaceV1, usize> for FreezeState {
fn event(
state: &mut Self,
surf: &ZwlrLayerSurfaceV1,
event: zwlr_layer_surface_v1::Event,
_: &usize,
_: &Connection,
_: &QueueHandle<Self>,
) {
if let zwlr_layer_surface_v1::Event::Configure { serial, .. } = event {
surf.ack_configure(serial);
state.configured_count += 1;
}
}
}
impl Dispatch<ZwlrLayerShellV1, ()> for FreezeState {
fn event(
_: &mut Self,
_: &ZwlrLayerShellV1,
_: zwlr_layer_shell_v1::Event,
_: &(),
_: &Connection,
_: &QueueHandle<Self>,
) {
}
}
impl Dispatch<zwlr_screencopy_manager_v1::ZwlrScreencopyManagerV1, ()> for FreezeState {
fn event(
_: &mut Self,
_: &zwlr_screencopy_manager_v1::ZwlrScreencopyManagerV1,
_: zwlr_screencopy_manager_v1::Event,
_: &(),
_: &Connection,
_: &QueueHandle<Self>,
) {
}
}
delegate_noop!(FreezeState: ignore wl_compositor::WlCompositor);
delegate_noop!(FreezeState: ignore wl_surface::WlSurface);
delegate_noop!(FreezeState: ignore wayland_client::protocol::wl_region::WlRegion);
delegate_noop!(FreezeState: ignore wl_shm_pool::WlShmPool);
delegate_noop!(FreezeState: ignore wl_buffer::WlBuffer);
delegate_noop!(FreezeState: ignore ZwpLinuxDmabufV1);
delegate_noop!(FreezeState: ignore ZwpLinuxBufferParamsV1);

1091
src/gui/annotations.rs Normal file

File diff suppressed because it is too large Load diff

929
src/gui/app.rs Normal file
View file

@ -0,0 +1,929 @@
//! Main application state for blast-gui.
//!
//! State machine:
//! Launcher -> (capture runs, window hides) -> Preview
//! Preview -> copy / save / copysave / discard -> Launcher
use std::{path::PathBuf, thread, time::Duration};
use eframe::egui::{self, ColorImage, Context, Pos2, TextureHandle, TextureOptions, Vec2};
use image::DynamicImage;
use super::annotations::{AnnotationLayer, DragState, Tool};
use super::{theme, widgets};
use crate::{
capture::{self, CaptureOptions},
clipboard,
error::BlastError,
freeze, hyprland, paths, region, select, wayland_windows,
shhh::{self, ShadowOptions},
};
#[derive(Debug, Clone, Copy, PartialEq)]
enum Subject {
Screen,
Active,
Output,
Area,
Region,
}
impl Subject {
const ALL: &'static [Subject] = &[
Subject::Screen,
Subject::Active,
Subject::Output,
Subject::Area,
Subject::Region,
];
fn label(self) -> &'static str {
match self {
Subject::Screen => "🖥 Screen",
Subject::Active => "🪟 Active",
Subject::Output => "📺 Output",
Subject::Area => "⊡ Area",
Subject::Region => "✂ Region",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
enum Action {
Copy,
Save,
Copysave,
}
impl Action {
const ALL: &'static [Action] = &[Action::Copy, Action::Save, Action::Copysave];
fn label(self) -> &'static str {
match self {
Action::Copy => "📋 Copy",
Action::Save => "💾 Save",
Action::Copysave => "📋+💾 Copy & Save",
}
}
}
#[derive(Debug, Clone)]
struct ShadowConfig {
enabled: bool,
radius: f32,
offset_x: f32,
offset_y: f32,
alpha: f32,
spread: f32,
blur: f32,
}
impl Default for ShadowConfig {
fn default() -> Self {
let bs = hyprland::border_size().unwrap_or(0) as f32;
let rnd = hyprland::rounding().unwrap_or(8) as f32;
Self {
enabled: true,
radius: rnd + bs,
offset_x: -20.0,
offset_y: -20.0,
alpha: 150.0,
spread: 26.0,
blur: 5.0,
}
}
}
impl ShadowConfig {
fn to_opts(&self) -> Option<ShadowOptions> {
if !self.enabled {
return None;
}
Some(ShadowOptions {
corner_radius: self.radius as u32,
offset: (self.offset_x as i32, self.offset_y as i32),
shadow_alpha: self.alpha as u8,
spread: self.spread as u32,
blur_radius: self.blur as u32,
})
}
}
enum AppState {
Launcher,
Capturing,
Preview {
image: DynamicImage,
texture: TextureHandle,
label: String,
status: Option<(String, bool)>, // (message, is_error)
zoom: f32, // 1.0 = fit, >1.0 = zoomed in
annotations: AnnotationLayer,
},
}
pub struct BlastApp {
state: AppState,
subject: Subject,
action: Action,
shadow: ShadowConfig,
delay: u32,
freeze: bool,
notify: bool,
// save path override
save_path: String,
}
impl BlastApp {
pub fn new(cc: &eframe::CreationContext<'_>) -> Self {
theme::apply(&cc.egui_ctx);
Self {
state: AppState::Launcher,
subject: Subject::Screen,
action: Action::Copysave,
shadow: ShadowConfig::default(),
delay: 0,
freeze: false,
notify: false,
save_path: String::new(),
}
}
fn do_capture(&mut self, ctx: &Context) {
self.state = AppState::Capturing;
ctx.send_viewport_cmd(egui::ViewportCommand::Minimized(true));
thread::sleep(Duration::from_millis(150));
let subject = self.subject;
let freeze = self.freeze;
let delay = self.delay;
let shadow_opts = self.shadow.to_opts();
let action = self.action;
let save_path = if self.save_path.trim().is_empty() {
None
} else {
Some(PathBuf::from(self.save_path.trim()))
};
let result: std::result::Result<(DynamicImage, String), String> = (|| {
if delay > 0 {
thread::sleep(Duration::from_secs(delay as u64));
}
let opts = CaptureOptions {
include_cursor: false,
scale: None,
};
let mut freeze_guard = if freeze && matches!(subject, Subject::Area | Subject::Region) {
freeze::FreezeGuard::spawn().unwrap_or_else(|_| freeze::FreezeGuard::none())
} else {
freeze::FreezeGuard::none()
};
let (raw_img, label): (DynamicImage, String) = match subject {
Subject::Screen => {
let img = capture::capture_screen(&opts).map_err(|e| e.to_string())?;
(img, "Screen".into())
}
Subject::Active => {
let win = hyprland::active_window().map_err(|e| e.to_string())?;
let bs = hyprland::border_size().unwrap_or(0);
let img = capture::capture_region(win.to_geometry(bs, 0), &opts)
.map_err(|e| e.to_string())?;
(img, format!("{} window", win.class))
}
Subject::Output => {
let mon = hyprland::focused_monitor().map_err(|e| e.to_string())?;
let img =
capture::capture_output(&mon.name, &opts).map_err(|e| e.to_string())?;
(img, mon.name.clone())
}
Subject::Area => {
let geom = hyprland::with_animations_disabled(|bs| {
let boxes = wayland_windows::visible_window_hints().unwrap_or_else(|| {
let bar = hyprland::get_option_int("plugin:hyprbars:bar_height")
.unwrap_or(0);
hyprland::visible_windows()
.map(|wins| {
wins.iter()
.map(|w| {
let g = w.to_geometry(bs, bar);
select::HintBox {
x: g.x as i32, y: g.y as i32,
w: g.w as i32, h: g.h as i32,
label: w.class.clone(),
}
})
.collect()
})
.unwrap_or_default()
});
region::select_area_boxes(boxes, None)
})
.map_err(|e| e.to_string())?;
freeze_guard.kill();
let img = capture::capture_region(geom, &opts).map_err(|e| e.to_string())?;
(img, "Area".into())
}
Subject::Region => {
let geom = region::select_free_region(None).map_err(|e| e.to_string())?;
freeze_guard.kill();
let img = capture::capture_region(geom, &opts).map_err(|e| e.to_string())?;
(img, "Region".into())
}
};
Ok((raw_img, label))
})();
ctx.send_viewport_cmd(egui::ViewportCommand::Minimized(false));
ctx.send_viewport_cmd(egui::ViewportCommand::Focus);
match result {
Err(e) => {
self.state = AppState::Launcher;
eprintln!("blast-gui: capture failed: {e}");
}
Ok((raw_img, label)) => {
let processed: DynamicImage = match &shadow_opts {
Some(opts) => shhh::apply_effects(&raw_img, opts).unwrap_or(raw_img.clone()),
None => raw_img.clone(),
};
// Execute action.
let status: Option<(String, bool)> = match action {
Action::Copy => {
let png = shhh::encode_png(&processed);
match png.and_then(|b| {
clipboard::copy_png(b)
.map_err(|e| crate::error::BlastError::Image(e.to_string()))
}) {
Ok(_) => Some(("Copied to clipboard".into(), false)),
Err(e) => Some((format!("Copy failed: {e}"), true)),
}
}
Action::Save => {
let dest = save_path.clone().unwrap_or_else(|| {
let prefix = hyprland::active_window()
.ok()
.map(|w| w.class)
.unwrap_or_default();
paths::default_save_path(Some(&prefix))
});
let png = shhh::encode_png(&processed);
match png.and_then(|b| std::fs::write(&dest, b).map_err(BlastError::Io)) {
Ok(_) => Some((format!("Saved to {}", dest.display()), false)),
Err(e) => Some((format!("Save failed: {e}"), true)),
}
}
Action::Copysave => {
let dest = save_path.clone().unwrap_or_else(|| {
let prefix = hyprland::active_window()
.ok()
.map(|w| w.class)
.unwrap_or_default();
paths::default_save_path(Some(&prefix))
});
let result = shhh::encode_png(&processed).and_then(|png| {
clipboard::copy_png(png.clone())
.map_err(|e| BlastError::Image(e.to_string()))?;
std::fs::write(&dest, &png).map_err(BlastError::Io)?;
Ok(dest.display().to_string())
});
match result {
Ok(path) => Some((format!("Copied & saved to {path}"), false)),
Err(e) => Some((format!("Failed: {e}"), true)),
}
}
};
let texture = image_to_texture(ctx, &processed, "preview");
self.state = AppState::Preview {
image: processed,
texture,
label,
status,
zoom: 1.0,
annotations: AnnotationLayer::default(),
};
}
}
}
}
impl eframe::App for BlastApp {
fn clear_color(&self, _visuals: &egui::Visuals) -> [f32; 4] {
[0.0, 0.0, 0.0, 0.0]
}
fn update(&mut self, ctx: &Context, _frame: &mut eframe::Frame) {
if ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
match &self.state {
AppState::Preview { .. } => self.state = AppState::Launcher,
_ => ctx.send_viewport_cmd(egui::ViewportCommand::Close),
}
}
egui::CentralPanel::default()
.frame(
egui::Frame::none()
.fill(theme::BG)
.rounding(egui::Rounding::same(theme::ROUNDING))
.inner_margin(egui::Margin::same(0.0)),
)
.show(ctx, |ui| {
// Title / drag bar.
ui.add_space(4.0);
widgets::drag_area(ui, egui::Id::new("title_bar"));
ui.add_space(4.0);
// Close button, top-right corner.
let close_rect = {
let r = ui.max_rect();
egui::Rect::from_min_size(
egui::pos2(r.right() - 32.0, r.top() + 4.0),
egui::vec2(24.0, 24.0),
)
};
let close_resp = ui.put(
close_rect,
egui::Button::new(egui::RichText::new("").color(theme::FG_DIM).size(12.0))
.fill(egui::Color32::TRANSPARENT)
.stroke(egui::Stroke::NONE),
);
if close_resp.clicked() {
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
}
match &self.state {
AppState::Launcher | AppState::Capturing => {
self.show_launcher(ui);
}
AppState::Preview { .. } => {
self.show_preview(ui);
}
}
});
// Trigger capture on the next frame after entering Capturing state
// (so the window has had a chance to hide).
// Use a flag via egui's request_repaint mechanism.
if matches!(self.state, AppState::Capturing) {}
}
}
impl BlastApp {
fn show_launcher(&mut self, ui: &mut egui::Ui) {
let mut do_capture = false;
egui::ScrollArea::vertical()
.auto_shrink([false, false])
.show(ui, |ui| {
ui.add_space(4.0);
egui::Frame::none()
.inner_margin(egui::Margin::symmetric(20.0, 8.0))
.show(ui, |ui| {
widgets::section_heading(ui, "SUBJECT");
ui.add_space(4.0);
ui.horizontal_wrapped(|ui| {
for &subj in Subject::ALL {
let sel = self.subject == subj;
if widgets::pill_button(ui, subj.label(), sel).clicked() {
self.subject = subj;
}
}
});
ui.add_space(12.0);
widgets::separator(ui);
widgets::section_heading(ui, "ACTION");
ui.add_space(4.0);
ui.horizontal_wrapped(|ui| {
for &act in Action::ALL {
let sel = self.action == act;
if widgets::pill_button(ui, act.label(), sel).clicked() {
self.action = act;
}
}
});
// Save path, shown when action involves saving.
if matches!(self.action, Action::Save | Action::Copysave) {
ui.add_space(8.0);
ui.horizontal(|ui| {
ui.label(
egui::RichText::new("Path:").color(theme::FG_DIM).size(12.5),
);
ui.add(
egui::TextEdit::singleline(&mut self.save_path)
.hint_text("~/Pictures/<random>.png")
.desired_width(260.0),
);
});
}
ui.add_space(12.0);
widgets::separator(ui);
// Shadow
widgets::section_heading(ui, "SHADOW & ROUNDING");
ui.add_space(4.0);
ui.horizontal(|ui| {
ui.checkbox(&mut self.shadow.enabled, "");
ui.label(
egui::RichText::new("Enable shadow & corner rounding")
.color(if self.shadow.enabled {
theme::FG
} else {
theme::FG_DIM
})
.size(13.5),
);
});
if self.shadow.enabled {
ui.add_space(6.0);
widgets::labelled_slider(
ui,
"Corner radius",
&mut self.shadow.radius,
0.0..=40.0,
);
widgets::labelled_slider(
ui,
"Spread ",
&mut self.shadow.spread,
0.0..=60.0,
);
widgets::labelled_slider(
ui,
"Blur ",
&mut self.shadow.blur,
0.0..=30.0,
);
widgets::labelled_slider(
ui,
"Opacity ",
&mut self.shadow.alpha,
0.0..=255.0,
);
widgets::labelled_slider(
ui,
"Offset X ",
&mut self.shadow.offset_x,
-60.0..=60.0,
);
widgets::labelled_slider(
ui,
"Offset Y ",
&mut self.shadow.offset_y,
-60.0..=60.0,
);
}
ui.add_space(12.0);
widgets::separator(ui);
widgets::section_heading(ui, "OPTIONS");
ui.add_space(4.0);
ui.horizontal(|ui| {
ui.checkbox(&mut self.freeze, "");
ui.label(
egui::RichText::new("Freeze screen during selection")
.color(theme::FG)
.size(13.5),
);
});
ui.horizontal(|ui| {
ui.checkbox(&mut self.notify, "");
ui.label(
egui::RichText::new("Desktop notification on complete")
.color(theme::FG)
.size(13.5),
);
});
ui.horizontal(|ui| {
ui.label(
egui::RichText::new("Delay (s):")
.color(theme::FG_DIM)
.size(12.5),
);
ui.add(
egui::DragValue::new(&mut self.delay)
.range(0..=30)
.speed(0.1),
);
});
ui.add_space(20.0);
ui.with_layout(egui::Layout::bottom_up(egui::Align::Center), |ui| {
ui.add_space(8.0);
if widgets::primary_button(ui, " Capture ").clicked() {
do_capture = true;
}
ui.add_space(4.0);
ui.label(
egui::RichText::new("Esc to close")
.color(theme::FG_DIM)
.size(11.0),
);
});
});
});
if do_capture {
// Clone context so we can pass it into do_capture.
let ctx = ui.ctx().clone();
self.do_capture(&ctx);
}
}
}
impl BlastApp {
fn show_preview(&mut self, ui: &mut egui::Ui) {
let (label, status) = match &self.state {
AppState::Preview { label, status, .. } => (label.clone(), status.clone()),
_ => return,
};
let mut go_back = false;
let mut do_copy = false;
let mut do_save = false;
let mut do_undo = false;
let mut do_clear = false;
let mut commit_text = false;
egui::Frame::none()
.inner_margin(egui::Margin::symmetric(12.0, 8.0))
.show(ui, |ui| {
ui.horizontal(|ui| {
ui.label(
egui::RichText::new(format!("Preview - {label}"))
.color(theme::FG)
.size(15.0)
.strong(),
);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
small_icon_btn(ui, "1:1", "Reset zoom").map(|_| {
if let AppState::Preview { zoom, .. } = &mut self.state { *zoom = 1.0; }
});
small_icon_btn(ui, "", "Zoom out").map(|_| {
if let AppState::Preview { zoom, .. } = &mut self.state {
*zoom = (*zoom / 1.25).max(0.1);
}
});
let zoom_pct = if let AppState::Preview { zoom, .. } = &self.state {
format!("{:.0}%", zoom * 100.0)
} else { "100%".into() };
ui.label(egui::RichText::new(zoom_pct).color(theme::FG_DIM).size(11.5));
small_icon_btn(ui, "+", "Zoom in").map(|_| {
if let AppState::Preview { zoom, .. } = &mut self.state {
*zoom = (*zoom * 1.25).min(8.0);
}
});
ui.label(egui::RichText::new("🔍").color(theme::FG_DIM).size(12.0));
});
});
ui.add_space(6.0);
egui::Frame::none()
.fill(theme::BG_PANEL)
.rounding(egui::Rounding::same(6.0))
.inner_margin(egui::Margin::symmetric(6.0, 4.0))
.show(ui, |ui| {
ui.horizontal_wrapped(|ui| {
// Tool pills
for &tool in Tool::ALL {
let sel = matches!(&self.state,
AppState::Preview { annotations, .. } if annotations.tool == tool);
if widgets::pill_button(ui, tool.label(), sel).clicked() {
if let AppState::Preview { annotations, .. } = &mut self.state {
annotations.tool = tool;
}
}
}
ui.separator();
// Fill checkbox, only shown when Box tool is active
let is_rect = matches!(&self.state,
AppState::Preview { annotations, .. } if annotations.tool == Tool::Rect);
if is_rect {
if let AppState::Preview { annotations, .. } = &mut self.state {
ui.checkbox(&mut annotations.rect_filled, "");
ui.label(egui::RichText::new("Fill").color(theme::FG_DIM).size(11.5));
}
ui.separator();
}
// Color picker
if let AppState::Preview { annotations, .. } = &mut self.state {
ui.color_edit_button_srgba(&mut annotations.color);
ui.label(egui::RichText::new("Color").color(theme::FG_DIM).size(11.5));
}
// Tool-specific size control:
// - Rect / Arrow / Pencil -> line thickness in px
// - Text -> font size
// - Select / Highlight / Pixelate -> nothing
let active_tool = if let AppState::Preview { annotations, .. } = &self.state {
Some(annotations.tool)
} else { None };
match active_tool {
Some(Tool::Rect) | Some(Tool::Arrow) | Some(Tool::Pencil) => {
ui.separator();
if let AppState::Preview { annotations, .. } = &mut self.state {
ui.add(
egui::Slider::new(&mut annotations.thickness, 1.0..=12.0)
.text("px")
.show_value(true)
.trailing_fill(true),
);
}
}
Some(Tool::Text) => {
ui.separator();
if let AppState::Preview { annotations, .. } = &mut self.state {
ui.add(
egui::Slider::new(&mut annotations.text_size, 10.0..=200.0)
.text("px")
.show_value(true)
.trailing_fill(true),
);
}
}
_ => {}
}
ui.separator();
// Undo / Clear
if widgets::secondary_button(ui, "↩ Undo").clicked() { do_undo = true; }
if widgets::secondary_button(ui, "🗑 Clear").clicked() { do_clear = true; }
});
});
ui.add_space(4.0);
// Text input box (shown when text tool active & placement chosen)
let show_text_input = matches!(&self.state,
AppState::Preview { annotations, .. }
if annotations.tool == Tool::Text && annotations.text_pending.is_some());
if show_text_input {
ui.horizontal(|ui| {
ui.label(egui::RichText::new("Text:").color(theme::FG_DIM).size(12.5));
if let AppState::Preview { annotations, .. } = &mut self.state {
// Ensure drag state is TextInput
if let Some(pos) = annotations.text_pending {
if !matches!(annotations.drag, DragState::TextInput { .. }) {
annotations.drag = DragState::TextInput { pos, buf: String::new() };
}
if let DragState::TextInput { ref mut buf, .. } = annotations.drag {
let resp = ui.add(
egui::TextEdit::singleline(buf)
.desired_width(240.0)
.hint_text("Type annotation…")
);
resp.request_focus();
if ui.input(|i| i.key_pressed(egui::Key::Enter)) {
commit_text = true;
}
}
}
}
if widgets::secondary_button(ui, "✓ Place").clicked() {
commit_text = true;
}
});
ui.add_space(4.0);
}
let _canvas_resp = if let AppState::Preview { texture, zoom, annotations, image, .. } = &mut self.state {
let avail_w = ui.available_width();
let avail_h = (ui.available_height() - 80.0).max(160.0);
let [tw, th] = [texture.size()[0] as f32, texture.size()[1] as f32];
let fit_scale = (avail_w / tw).min(avail_h / th).min(1.0);
let display_scale = fit_scale * *zoom;
let img_size = Vec2::new(tw * display_scale, th * display_scale);
let scroll_out = egui::ScrollArea::both()
.auto_shrink([false, false])
.max_height(avail_h)
.show(ui, |ui| {
let (rect, resp) = ui.allocate_exact_size(
img_size,
egui::Sense::click_and_drag(),
);
ui.painter().image(
texture.id(),
rect,
egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)),
egui::Color32::WHITE,
);
let to_img = |sp: Pos2| -> Pos2 {
Pos2::new(
(sp.x - rect.min.x) / display_scale,
(sp.y - rect.min.y) / display_scale,
)
};
let to_screen = |ip: Pos2| -> Pos2 {
Pos2::new(
rect.min.x + ip.x * display_scale,
rect.min.y + ip.y * display_scale,
)
};
annotations.handle_input(ui, rect, to_img);
annotations.paint(ui.painter(), to_screen, image, display_scale);
(rect, resp)
});
let hover_pos = ui.input(|i| i.pointer.hover_pos().unwrap_or_default());
if scroll_out.inner_rect.contains(hover_pos) {
let delta = ui.input(|i| i.smooth_scroll_delta.y);
if delta != 0.0 {
let factor = if delta > 0.0 { 1.08 } else { 1.0 / 1.08 };
*zoom = (*zoom * factor).clamp(0.1, 8.0);
ui.ctx().request_repaint();
}
}
Some(scroll_out.inner.1) // canvas response
} else {
None
};
ui.add_space(6.0);
if let Some((msg, is_err)) = &status {
widgets::status_label(ui, msg, *is_err);
ui.add_space(4.0);
}
widgets::separator(ui);
ui.horizontal(|ui| {
if widgets::secondary_button(ui, "← Back").clicked() { go_back = true; }
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if widgets::primary_button(ui, "📋 Copy").clicked() { do_copy = true; }
ui.add_space(4.0);
if widgets::secondary_button(ui, "💾 Save").clicked() { do_save = true; }
});
});
});
if go_back {
self.state = AppState::Launcher;
return;
}
if do_undo {
if let AppState::Preview { annotations, .. } = &mut self.state {
annotations.undo();
}
}
if do_clear {
if let AppState::Preview { annotations, .. } = &mut self.state {
annotations.clear();
}
}
if commit_text {
if let AppState::Preview { annotations, .. } = &mut self.state {
annotations.commit_text();
}
}
// Build a flattened image (base + annotations) for copy/save
let flat_image = if do_copy || do_save {
if let AppState::Preview {
image, annotations, ..
} = &self.state
{
Some(super::annotations::flatten(image, &annotations.annotations))
} else {
None
}
} else {
None
};
if do_copy {
let img =
flat_image
.as_ref()
.or(if let AppState::Preview { image, .. } = &self.state {
Some(image)
} else {
None
});
if let Some(img) = img {
let img = img.clone();
let opts = self.shadow.to_opts();
let result = (|| -> std::result::Result<(), String> {
let png = match &opts {
Some(o) => shhh::apply_and_encode(&img, o),
None => shhh::encode_png(&img),
}
.map_err(|e| e.to_string())?;
clipboard::copy_png(png).map_err(|e| e.to_string())?;
Ok(())
})();
let status = match result {
Ok(_) => ("Copied to clipboard".into(), false),
Err(e) => (format!("Copy failed: {e}"), true),
};
if let AppState::Preview { status: s, .. } = &mut self.state {
*s = Some(status);
}
}
}
if do_save {
let img =
flat_image
.as_ref()
.or(if let AppState::Preview { image, .. } = &self.state {
Some(image)
} else {
None
});
if let Some(img) = img {
let img = img.clone();
let opts = self.shadow.to_opts();
let save_path = if self.save_path.trim().is_empty() {
None
} else {
Some(PathBuf::from(self.save_path.trim()))
};
let dest = save_path.unwrap_or_else(|| {
let prefix = hyprland::active_window()
.ok()
.map(|w| w.class)
.unwrap_or_default();
paths::default_save_path(Some(&prefix))
});
let result = (|| -> std::result::Result<String, String> {
let png = match &opts {
Some(o) => shhh::apply_and_encode(&img, o),
None => shhh::encode_png(&img),
}
.map_err(|e| e.to_string())?;
std::fs::write(&dest, &png).map_err(|e| e.to_string())?;
Ok(dest.display().to_string())
})();
let status = match result {
Ok(p) => (format!("Saved to {p}"), false),
Err(e) => (format!("Save failed: {e}"), true),
};
if let AppState::Preview { status: s, .. } = &mut self.state {
*s = Some(status);
}
}
}
}
}
fn small_icon_btn(ui: &mut egui::Ui, label: &str, _tooltip: &str) -> Option<egui::Response> {
let resp = ui.add(
egui::Button::new(egui::RichText::new(label).color(theme::FG_DIM).size(13.0))
.fill(theme::BG_PANEL)
.stroke(egui::Stroke::new(1.0, theme::ACCENT_DIM))
.rounding(egui::Rounding::same(theme::ROUNDING))
.min_size(egui::Vec2::new(28.0, 22.0)),
);
if resp.clicked() {
Some(resp)
} else {
None
}
}
fn image_to_texture(ctx: &Context, img: &DynamicImage, name: &str) -> TextureHandle {
let rgba = img.to_rgba8();
let (w, h) = rgba.dimensions();
let pixels: Vec<egui::Color32> = rgba
.pixels()
.map(|p| egui::Color32::from_rgba_unmultiplied(p[0], p[1], p[2], p[3]))
.collect();
ctx.load_texture(
name,
ColorImage {
size: [w as usize, h as usize],
pixels,
},
TextureOptions::LINEAR,
)
}

52
src/gui/main.rs Normal file
View file

@ -0,0 +1,52 @@
// blast egui launcher + post-capture preview
// Pull in shared backend modules.
#[path = "../capture.rs"]
mod capture;
#[path = "../clipboard.rs"]
mod clipboard;
#[path = "../error.rs"]
mod error;
#[path = "../freeze.rs"]
mod freeze;
#[path = "../hyprland.rs"]
mod hyprland;
#[path = "../notify.rs"]
mod notify;
#[path = "../paths.rs"]
mod paths;
#[path = "../region.rs"]
mod region;
#[path = "../select.rs"]
mod select;
#[path = "../shhh.rs"]
mod shhh;
#[path = "../wayland_windows.rs"]
mod wayland_windows;
mod annotations;
mod app;
mod theme;
mod widgets;
use eframe::egui;
fn main() {
let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_title("blast")
.with_inner_size([520.0, 560.0])
.with_min_inner_size([400.0, 440.0])
.with_resizable(true)
.with_decorations(false) // borderless
.with_transparent(true),
..Default::default()
};
eframe::run_native(
"blast",
options,
Box::new(|cc| Ok(Box::new(app::BlastApp::new(cc)))),
)
.expect("failed to start blast-gui");
}

97
src/gui/theme.rs Normal file
View file

@ -0,0 +1,97 @@
//! Palette derived from neomodern neovim theme.
//! https://github.com/casedami/neomodern.nvim
use eframe::egui::{self, Color32, FontId, Rounding, Stroke, Style, Visuals};
// Base surfaces
pub const BG: Color32 = Color32::from_rgb(0x17, 0x17, 0x19);
pub const BG_PANEL: Color32 = Color32::from_rgb(0x1F, 0x1F, 0x21);
pub const BG_HOVER: Color32 = Color32::from_rgb(0x2A, 0x2A, 0x2B);
// Interactive states
pub const BG_ACTIVE: Color32 = Color32::from_rgb(0x6C, 0x81, 0xB8);
pub const ACCENT: Color32 = Color32::from_rgb(0xAB, 0xBD, 0xEE);
pub const ACCENT_DIM: Color32 = Color32::from_rgb(0x6E, 0x6D, 0xA7);
// Text
pub const FG: Color32 = Color32::from_rgb(0xA0, 0xA0, 0xAF);
pub const FG_DIM: Color32 = Color32::from_rgb(0x61, 0x61, 0x6A);
// Semantic
pub const SUCCESS: Color32 = Color32::from_rgb(0x73, 0x89, 0x80);
pub const ERROR: Color32 = Color32::from_rgb(0xA6, 0x7E, 0x97);
pub const ROUNDING: f32 = 0.0;
pub fn apply(ctx: &egui::Context) {
let mut style = Style::default();
style.visuals = Visuals {
dark_mode: true,
window_fill: BG,
panel_fill: BG_PANEL,
faint_bg_color: BG_PANEL,
extreme_bg_color: BG,
window_rounding: Rounding::same(ROUNDING),
window_stroke: Stroke::new(1.0, ACCENT_DIM),
widgets: {
let mut w = egui::style::Widgets::default();
// inactive
w.inactive.bg_fill = BG_PANEL;
w.inactive.weak_bg_fill = BG_PANEL;
w.inactive.bg_stroke = Stroke::new(1.0, FG_DIM);
w.inactive.fg_stroke = Stroke::new(1.0, FG_DIM);
w.inactive.rounding = Rounding::same(ROUNDING);
// hovered
w.hovered.bg_fill = BG_HOVER;
w.hovered.weak_bg_fill = BG_HOVER;
w.hovered.bg_stroke = Stroke::new(1.0, ACCENT);
w.hovered.fg_stroke = Stroke::new(1.5, FG);
w.hovered.rounding = Rounding::same(ROUNDING);
// active (pressed)
w.active.bg_fill = BG_ACTIVE;
w.active.weak_bg_fill = BG_ACTIVE;
w.active.bg_stroke = Stroke::new(1.5, ACCENT);
w.active.fg_stroke = Stroke::new(2.0, Color32::WHITE);
w.active.rounding = Rounding::same(ROUNDING);
// open (selected/toggled)
w.open.bg_fill = BG_ACTIVE;
w.open.weak_bg_fill = BG_ACTIVE;
w.open.bg_stroke = Stroke::new(1.5, ACCENT);
w.open.fg_stroke = Stroke::new(2.0, Color32::WHITE);
w.open.rounding = Rounding::same(ROUNDING);
// noninteractive
w.noninteractive.bg_fill = BG_PANEL;
w.noninteractive.weak_bg_fill = BG_PANEL;
w.noninteractive.bg_stroke = Stroke::new(1.0, FG_DIM);
w.noninteractive.fg_stroke = Stroke::new(1.0, FG);
w.noninteractive.rounding = Rounding::same(ROUNDING);
w
},
selection: egui::style::Selection {
bg_fill: BG_HOVER,
stroke: Stroke::new(1.0, ACCENT),
},
hyperlink_color: ACCENT,
override_text_color: Some(FG),
..Visuals::dark()
};
style.spacing.item_spacing = egui::vec2(8.0, 6.0);
style.spacing.button_padding = egui::vec2(12.0, 7.0);
style.spacing.window_margin = egui::Margin::same(16.0);
style.spacing.scroll = egui::style::ScrollStyle::solid();
style
.text_styles
.insert(egui::TextStyle::Body, FontId::proportional(14.0));
style
.text_styles
.insert(egui::TextStyle::Button, FontId::proportional(14.0));
style
.text_styles
.insert(egui::TextStyle::Heading, FontId::proportional(18.0));
style
.text_styles
.insert(egui::TextStyle::Small, FontId::proportional(11.0));
style
.text_styles
.insert(egui::TextStyle::Monospace, FontId::monospace(13.0));
ctx.set_style(style);
}

130
src/gui/widgets.rs Normal file
View file

@ -0,0 +1,130 @@
use eframe::egui::{self, Color32, Response, RichText, Sense, Ui, Vec2};
use super::theme;
/// A pill shaped toggle button. Returns true when clicked.
pub fn pill_button(ui: &mut Ui, label: &str, selected: bool) -> Response {
let (bg, fg) = if selected {
(theme::BG_ACTIVE, Color32::WHITE)
} else {
(theme::BG_PANEL, theme::FG_DIM)
};
let text = RichText::new(label).color(fg).size(13.5);
let btn = egui::Button::new(text)
.fill(bg)
.stroke(egui::Stroke::new(
if selected { 1.5 } else { 1.0 },
if selected {
theme::ACCENT
} else {
theme::ACCENT_DIM
},
))
.rounding(egui::Rounding::same(theme::ROUNDING))
.min_size(Vec2::new(80.0, 30.0));
ui.add(btn)
}
/// Large action button (e.g. "Capture")
pub fn primary_button(ui: &mut Ui, label: &str) -> Response {
let text = RichText::new(label)
.color(Color32::WHITE)
.size(15.0)
.strong();
ui.add(
egui::Button::new(text)
.fill(theme::ACCENT)
.stroke(egui::Stroke::new(1.0, theme::ACCENT))
.rounding(egui::Rounding::same(theme::ROUNDING))
.min_size(Vec2::new(120.0, 36.0)),
)
}
/// Secondary action button (e.g. "Copy", "Save")
pub fn secondary_button(ui: &mut Ui, label: &str) -> Response {
let text = RichText::new(label).color(theme::FG).size(13.5);
ui.add(
egui::Button::new(text)
.fill(theme::BG_PANEL)
.stroke(egui::Stroke::new(1.0, theme::ACCENT_DIM))
.rounding(egui::Rounding::same(theme::ROUNDING))
.min_size(Vec2::new(90.0, 32.0)),
)
}
/// Heading label
pub fn section_heading(ui: &mut Ui, label: &str) {
ui.label(
RichText::new(label)
.color(theme::FG_DIM)
.size(11.0)
.strong(),
);
}
/// Status badge: green for success, red for error, dimmed for info.
pub fn status_label(ui: &mut Ui, msg: &str, is_error: bool) {
let color = if is_error {
theme::ERROR
} else {
theme::SUCCESS
};
ui.label(RichText::new(msg).color(color).size(12.5));
}
/// Thin horizontal rule.
pub fn separator(ui: &mut Ui) {
let rect = ui.available_rect_before_wrap();
let y = ui.cursor().min.y + 4.0;
ui.painter().hline(
rect.left()..=rect.right(),
y,
egui::Stroke::new(1.0, theme::ACCENT_DIM),
);
ui.add_space(10.0);
}
/// Labelled slider returning the new value.
pub fn labelled_slider(
ui: &mut Ui,
label: &str,
value: &mut f32,
range: std::ops::RangeInclusive<f32>,
) {
ui.horizontal(|ui| {
ui.label(RichText::new(label).color(theme::FG_DIM).size(12.5));
ui.add(
egui::Slider::new(value, range)
.show_value(true)
.trailing_fill(true),
);
});
}
/// Drag area for a borderless window.
/// Call this at the very top of your central panel. Returns the response
/// Caller can check for right-click etc.
pub fn drag_area(ui: &mut Ui, _id: egui::Id) -> Response {
let (rect, resp) = ui.allocate_exact_size(
egui::vec2(ui.available_width(), 24.0),
Sense::click_and_drag(),
);
if resp.dragged() {
ui.ctx().send_viewport_cmd(egui::ViewportCommand::StartDrag);
}
// Title bar label
ui.painter().text(
rect.center(),
egui::Align2::CENTER_CENTER,
"blasted",
egui::FontId::proportional(13.0),
theme::FG_DIM,
);
resp
}

268
src/hyprland.rs Normal file
View file

@ -0,0 +1,268 @@
//! Direct Hyprland IPC client.
use std::{
env,
io::{Read, Write},
os::unix::net::UnixStream,
path::PathBuf,
time::Duration,
};
use serde::Deserialize;
use crate::error::{BlastError, Result};
fn his() -> Result<String> {
env::var("HYPRLAND_INSTANCE_SIGNATURE").map_err(|_| {
BlastError::Hyprland("HYPRLAND_INSTANCE_SIGNATURE not set ->>> is Hyprland running?".into())
})
}
fn runtime_dir() -> PathBuf {
env::var("XDG_RUNTIME_DIR")
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from("/run/user/1000"))
}
pub fn socket1_path() -> Result<PathBuf> {
Ok(runtime_dir().join("hypr").join(his()?).join(".socket.sock"))
}
#[allow(dead_code)]
pub fn socket2_path() -> Result<PathBuf> {
Ok(runtime_dir()
.join("hypr")
.join(his()?)
.join(".socket2.sock"))
}
// raw IPC
pub fn ipc_request(command: &str) -> Result<String> {
let path = socket1_path()?;
let mut stream = UnixStream::connect(&path)
.map_err(|e| BlastError::Hyprland(format!("connect to {path:?}: {e}")))?;
stream
.write_all(command.as_bytes())
.map_err(|e| BlastError::Hyprland(format!("write: {e}")))?;
stream.set_read_timeout(Some(Duration::from_secs(5))).ok();
let mut response = String::new();
stream
.read_to_string(&mut response)
.map_err(|e| BlastError::Hyprland(format!("read: {e}")))?;
Ok(response)
}
pub fn ipc_json(command: &str) -> Result<String> {
ipc_request(&format!("j/{command}"))
}
#[derive(Debug, Deserialize)]
pub struct WindowInfo {
pub at: [i64; 2],
pub size: [i64; 2],
pub class: String,
#[allow(dead_code)]
pub title: String,
pub workspace: WorkspaceRef,
}
#[derive(Debug, Deserialize, Clone)]
pub struct WorkspaceRef {
pub id: i64,
#[allow(dead_code)]
pub name: String,
}
/// Full monitor description from j/monitors.
#[derive(Debug, Deserialize, Clone)]
pub struct MonitorInfo {
pub name: String,
pub focused: bool,
/// Logical X origin of this monitor in the global compositor space.
pub x: i64,
/// Logical Y origin of this monitor in the global compositor space.
pub y: i64,
/// Physical pixel width (raw resolution, before scale).
pub width: i64,
/// Physical pixel height (raw resolution, before scale).
pub height: i64,
/// Fractional scale factor (e.g. 1.5, 2.0).
pub scale: f64,
#[serde(rename = "activeWorkspace")]
pub active_workspace: WorkspaceRef,
}
impl MonitorInfo {
/// Logical width = physical ÷ scale. This is the coordinate space
pub fn logical_width(&self) -> i64 {
(self.width as f64 / self.scale).round() as i64
}
/// Logical height = physical ÷ scale.
pub fn logical_height(&self) -> i64 {
(self.height as f64 / self.scale).round() as i64
}
/// True if the logical point (px, py) lies within this monitor's logical bounds.
pub fn contains_point(&self, px: i64, py: i64) -> bool {
px >= self.x
&& px < self.x + self.logical_width()
&& py >= self.y
&& py < self.y + self.logical_height()
}
/// True if any corner of the geometry overlaps this monitor's logical bounds.
pub fn contains_geometry(&self, g: &Geometry) -> bool {
self.contains_point(g.x, g.y)
|| self.contains_point(g.x + g.w - 1, g.y)
|| self.contains_point(g.x, g.y + g.h - 1)
|| self.contains_point(g.x + g.w - 1, g.y + g.h - 1)
}
}
#[derive(Debug, Deserialize)]
#[allow(dead_code)]
pub struct AnimationEntry {
pub name: String,
pub enabled: bool,
pub speed: f64,
pub bezier: String,
}
#[derive(Debug, Deserialize)]
struct OptionInt {
int: i64,
}
pub fn active_window() -> Result<WindowInfo> {
let json = ipc_json("activewindow")?;
serde_json::from_str(&json)
.map_err(|e| BlastError::Hyprland(format!("parse activewindow: {e}\nraw: {json}")))
}
pub fn monitors() -> Result<Vec<MonitorInfo>> {
let json = ipc_json("monitors")?;
serde_json::from_str(&json).map_err(|e| BlastError::Hyprland(format!("parse monitors: {e}")))
}
pub fn focused_monitor() -> Result<MonitorInfo> {
monitors()?
.into_iter()
.find(|m| m.focused)
.ok_or_else(|| BlastError::Hyprland("no focused monitor found".into()))
}
pub fn monitor_for_geometry(g: &Geometry) -> Result<MonitorInfo> {
let mons = monitors()?;
// try monitor that contains the top-left corner.
if let Some(m) = mons.iter().find(|m| m.contains_geometry(g)) {
return Ok(m.clone());
}
// Fallback: focused, then first.
mons.iter()
.find(|m| m.focused)
.or_else(|| mons.first())
.cloned()
.ok_or_else(|| BlastError::Hyprland("no monitors found".into()))
}
pub fn clients() -> Result<Vec<WindowInfo>> {
let json = ipc_json("clients")?;
serde_json::from_str(&json).map_err(|e| BlastError::Hyprland(format!("parse clients: {e}")))
}
pub fn active_workspace_ids() -> Result<Vec<i64>> {
Ok(monitors()?.iter().map(|m| m.active_workspace.id).collect())
}
pub fn visible_windows() -> Result<Vec<WindowInfo>> {
let active_ids = active_workspace_ids()?;
Ok(clients()?
.into_iter()
.filter(|w| active_ids.contains(&w.workspace.id))
.collect())
}
pub fn get_option_int(option: &str) -> Result<i64> {
let json = ipc_json(&format!("getoption {option}"))?;
let opt: OptionInt = serde_json::from_str(&json)
.map_err(|e| BlastError::Hyprland(format!("parse option {option}: {e}")))?;
Ok(opt.int)
}
pub fn border_size() -> Result<i64> {
get_option_int("general:border_size")
}
pub fn rounding() -> Result<i64> {
get_option_int("decoration:rounding")
}
pub fn set_keyword(keyword: &str, value: &str) -> Result<()> {
ipc_request(&format!("keyword {keyword} {value}"))?;
Ok(())
}
pub fn get_fade_animations() -> Result<(String, String)> {
let json = ipc_json("animations")?;
let outer: Vec<Vec<serde_json::Value>> = serde_json::from_str(&json)
.map_err(|e| BlastError::Hyprland(format!("parse animations: {e}")))?;
let entries: Vec<serde_json::Value> = outer.into_iter().flatten().collect();
let find = |name: &str| -> Option<String> {
entries.iter().find(|e| e["name"] == name).map(|e| {
let enabled = if e["enabled"].as_bool().unwrap_or(false) {
"1"
} else {
"0"
};
let speed = e["speed"].as_f64().unwrap_or(1.0).floor() as i64;
let bezier = e["bezier"].as_str().unwrap_or("default");
format!("{name},{enabled},{speed},{bezier}")
})
};
Ok((
find("fade").unwrap_or_else(|| "fade,1,3,default".into()),
find("fadeOut").unwrap_or_else(|| "fadeOut,1,3,default".into()),
))
}
pub fn with_animations_disabled<F, T>(f: F) -> Result<T>
where
F: FnOnce(i64) -> Result<T>,
{
let (fade, fade_out) = get_fade_animations()?;
let bs = border_size()?;
set_keyword("animation", "fade,0,1,default")?;
set_keyword("animation", "fadeOut,0,1,default")?;
let result = f(bs);
let _ = set_keyword("animation", &fade);
let _ = set_keyword("animation", &fade_out);
result
}
// Geom
#[derive(Debug, Clone, Copy)]
pub struct Geometry {
pub x: i64,
pub y: i64,
pub w: i64,
pub h: i64,
}
impl Geometry {
#[allow(dead_code)]
pub fn to_geom_string(self) -> String {
format!("{},{} {}x{}", self.x, self.y, self.w, self.h)
}
}
impl WindowInfo {
pub fn to_geometry(&self, border_size: i64, bar_height: i64) -> Geometry {
Geometry {
x: self.at[0] - border_size,
y: self.at[1] - border_size - bar_height,
w: self.size[0] + border_size * 2,
h: self.size[1] + border_size * 2 + bar_height,
}
}
}

285
src/lib.rs Normal file
View file

@ -0,0 +1,285 @@
//! corner rounding and dropshadow for RGBA images.
use image::{imageops, DynamicImage, GenericImageView, ImageBuffer, ImageError, Rgba};
use png::{BitDepth, ColorType, Encoder};
use thiserror::Error;
#[derive(Debug, Error)]
pub enum ShhhError {
#[error("image decode error: {0}")]
Decode(#[from] ImageError),
#[error("PNG encode error: {0}")]
Encode(#[from] png::EncodingError),
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("format detection failed: {0}")]
Format(String),
}
#[derive(Debug, Clone)]
pub struct ShadowOptions {
/// Corner rounding radius in pixels (default 8).
pub corner_radius: u32,
/// Shadow offset as (x, y) pixels (default (-20, -20)).
pub offset: (i32, i32),
/// Shadow opacity 0255 (default 150).
pub shadow_alpha: u8,
/// Shadow spread distance in pixels (default 26).
pub spread: u32,
/// Internal blur radius applied to the shadow layer (default 5).
pub blur_radius: u32,
}
impl Default for ShadowOptions {
fn default() -> Self {
Self {
corner_radius: 8,
offset: (-20, -20),
shadow_alpha: 150,
spread: 26,
blur_radius: 5,
}
}
}
/// Process raw PNG bytes: round corners, add drop shadow, return PNG bytes.
pub fn process_image_bytes(input: &[u8], opts: &ShadowOptions) -> Result<Vec<u8>, ShhhError> {
let img = decode_image(input)?;
let processed = apply_effects(&img, opts)?;
encode_png(&processed)
}
/// Process an already-decoded [DynamicImage] and return a new one.
///
/// Hold an image in memory and want to avoid an extra encode/decode roundtrip.
pub fn apply_effects(img: &DynamicImage, opts: &ShadowOptions) -> Result<DynamicImage, ShhhError> {
let rounded = round_corners(img, opts.corner_radius);
let shadowed = add_rounded_drop_shadow(
&rounded,
opts.offset.0,
opts.offset.1,
opts.blur_radius,
opts.spread,
opts.shadow_alpha,
)?;
Ok(shadowed)
}
/// Decode raw bytes into a [DynamicImage].
pub fn decode_image(bytes: &[u8]) -> Result<DynamicImage, ShhhError> {
use image::ImageReader;
let reader = ImageReader::new(std::io::Cursor::new(bytes))
.with_guessed_format()
.map_err(|e| ShhhError::Format(e.to_string()))?;
Ok(reader.decode()?)
}
/// Encode a [DynamicImage] to PNG bytes (RGBA8).
pub fn encode_png(img: &DynamicImage) -> Result<Vec<u8>, ShhhError> {
let rgba = img.to_rgba8();
let (width, height) = rgba.dimensions();
let mut out: Vec<u8> = Vec::new();
{
let mut encoder = Encoder::new(&mut out, width, height);
encoder.set_color(ColorType::Rgba);
encoder.set_depth(BitDepth::Eight);
let mut writer = encoder.write_header()?;
writer.write_image_data(rgba.as_raw())?;
}
Ok(out)
}
// Image post-processing internals
/// Apply anti-aliased corner rounding to an image.
pub fn round_corners(img: &DynamicImage, radius: u32) -> DynamicImage {
let (width, height) = img.dimensions();
let mut rounded: ImageBuffer<Rgba<u8>, Vec<u8>> = ImageBuffer::new(width, height);
let r = radius as f32;
for (x, y, pixel) in img.to_rgba8().enumerate_pixels() {
// Determine which corner quadrant, if any, this pixel falls into.
let corner: Option<(f32, f32)> = if x < radius && y < radius {
Some((r - x as f32, r - y as f32))
} else if x >= width - radius && y < radius {
Some((x as f32 - (width as f32 - r - 1.0), r - y as f32))
} else if x < radius && y >= height - radius {
Some((r - x as f32, y as f32 - (height as f32 - r - 1.0)))
} else if x >= width - radius && y >= height - radius {
Some((
x as f32 - (width as f32 - r - 1.0),
y as f32 - (height as f32 - r - 1.0),
))
} else {
None
};
match corner {
None => rounded.put_pixel(x, y, *pixel),
Some((dx, dy)) => {
let dist = (dx * dx + dy * dy).sqrt();
if dist <= r {
rounded.put_pixel(x, y, *pixel);
} else {
// Anti-alias: fade alpha over the last pixel.
let alpha = ((r + 1.0 - dist).max(0.0) * 255.0) as u8;
rounded.put_pixel(
x,
y,
Rgba([pixel[0], pixel[1], pixel[2], alpha.min(pixel[3])]),
);
}
}
}
}
DynamicImage::ImageRgba8(rounded)
}
/// Composite a dropshadow behind a rounded image.
pub fn add_rounded_drop_shadow(
img: &DynamicImage,
offset_x: i32,
offset_y: i32,
blur_radius: u32,
spread: u32,
shadow_alpha: u8,
) -> Result<DynamicImage, ShhhError> {
let (width, height) = img.dimensions();
let padding = spread + blur_radius * 2;
let total_w = (width as i32 + offset_x.abs() + padding as i32 * 2) as u32;
let total_h = (height as i32 + offset_y.abs() + padding as i32 * 2) as u32;
let mut output: ImageBuffer<Rgba<u8>, Vec<u8>> = ImageBuffer::new(total_w, total_h);
let shadow = create_shadow(img, blur_radius, spread, shadow_alpha);
// Shadow position: pushed back relative to the image.
let shadow_x: i64 = if offset_x >= 0 {
padding as i64
} else {
padding as i64 + offset_x as i64
};
let shadow_y: i64 = if offset_y >= 0 {
padding as i64
} else {
padding as i64 + offset_y as i64
};
imageops::overlay(&mut output, &shadow, shadow_x, shadow_y);
// Image position: pushed forward relative to the shadow.
let image_x: i64 = if offset_x >= 0 {
padding as i64 + offset_x as i64
} else {
padding as i64
};
let image_y: i64 = if offset_y >= 0 {
padding as i64 + offset_y as i64
} else {
padding as i64
};
imageops::overlay(&mut output, img, image_x, image_y);
Ok(DynamicImage::ImageRgba8(output))
}
/// Build the blurred shadow layer for a given image.
fn create_shadow(
img: &DynamicImage,
blur_radius: u32,
spread: u32,
shadow_alpha: u8,
) -> DynamicImage {
let (width, height) = img.dimensions();
let padding = spread + blur_radius * 2;
let new_w = width + padding * 2;
let new_h = height + padding * 2;
let mut shadow: ImageBuffer<Rgba<u8>, Vec<u8>> = ImageBuffer::new(new_w, new_h);
imageops::overlay(&mut shadow, &img.to_rgba8(), padding as i64, padding as i64);
// Flatten to a solid black mask preserving original alpha.
for pixel in shadow.pixels_mut() {
let alpha = pixel[3] as f32 / 255.0 * shadow_alpha as f32;
*pixel = Rgba([0, 0, 0, alpha as u8]);
}
let adjusted_blur = blur_radius + (spread as f32 / 2.0) as u32;
let blurred = imageops::blur(&shadow, adjusted_blur as f32);
// Soften the shadow edges with a sqrt gamma curve.
let mut cleaned: ImageBuffer<Rgba<u8>, Vec<u8>> = ImageBuffer::new(new_w, new_h);
for (x, y, pixel) in blurred.enumerate_pixels() {
if pixel[3] > 0 {
let factor = (pixel[3] as f32 / 255.0).powf(0.5);
cleaned.put_pixel(
x,
y,
Rgba([
(pixel[0] as f32 * factor) as u8,
(pixel[1] as f32 * factor) as u8,
(pixel[2] as f32 * factor) as u8,
(pixel[3] as f32 * factor) as u8,
]),
);
}
}
DynamicImage::ImageRgba8(cleaned)
}
// tests
#[cfg(test)]
mod tests {
use super::*;
use image::{ImageBuffer, Rgba};
fn solid_rgba(w: u32, h: u32, color: Rgba<u8>) -> DynamicImage {
DynamicImage::ImageRgba8(ImageBuffer::from_pixel(w, h, color))
}
#[test]
fn round_corners_shrinks_corners() {
let img = solid_rgba(100, 100, Rgba([255, 0, 0, 255]));
let rounded = round_corners(&img, 10);
let px = rounded.to_rgba8().get_pixel(0, 0).clone();
assert_eq!(px[3], 0, "corner pixel alpha should be 0");
let center = rounded.to_rgba8().get_pixel(50, 50).clone();
assert_eq!(center[3], 255);
}
#[test]
fn shadow_expands_canvas() {
let img = solid_rgba(100, 100, Rgba([255, 255, 255, 255]));
let opts = ShadowOptions::default();
let result = apply_effects(&img, &opts).unwrap();
let (w, h) = result.dimensions();
assert!(w > 100, "shadow should expand canvas width");
assert!(h > 100, "shadow should expand canvas height");
}
#[test]
fn encode_decode_roundtrip() {
let img = solid_rgba(64, 64, Rgba([128, 64, 32, 255]));
let bytes = encode_png(&img).unwrap();
let decoded = decode_image(&bytes).unwrap();
assert_eq!(decoded.dimensions(), (64, 64));
}
#[test]
fn process_image_bytes_end_to_end() {
let img = solid_rgba(50, 50, Rgba([200, 100, 50, 255]));
let input_bytes = encode_png(&img).unwrap();
let opts = ShadowOptions {
corner_radius: 5,
..ShadowOptions::default()
};
let output = process_image_bytes(&input_bytes, &opts).unwrap();
assert!(!output.is_empty());
let decoded = decode_image(&output).unwrap();
let (w, h) = decoded.dimensions();
assert!(w > 50 && h > 50);
}
}

490
src/main.rs Normal file
View file

@ -0,0 +1,490 @@
mod capture;
mod clipboard;
mod error;
mod freeze;
mod hyprland;
mod notify;
mod paths;
mod region;
mod select;
mod shhh;
mod wayland_windows;
use std::{
io::{self, Write},
path::PathBuf,
process,
};
use clap::{Args, Parser, Subcommand, ValueEnum};
use image::DynamicImage;
use shhh::{encode_png, ShadowOptions};
use capture::CaptureOptions;
use error::{BlastError, Result};
#[derive(Debug, Parser)]
#[command(name = "blast", about = "Hyprland screenshot tool")]
struct Cli {
#[arg(short = 'n', long)]
notify: bool,
#[arg(short = 'c', long)]
cursor: bool,
#[arg(short = 'f', long)]
freeze: bool,
#[arg(short = 'w', long, value_name = "N")]
wait: Option<u64>,
#[arg(short = 's', long, value_name = "SCALE")]
scale: Option<f64>,
/// Hint-box highlight colour for area/region selection (RRGGBBAA hex, e.g. ff550080).
#[arg(long = "hint-color", value_name = "RRGGBBAA", value_parser = parse_color)]
hint_color: Option<u32>,
#[command(flatten)]
shadow: ShadowArgs,
#[command(subcommand)]
action: Option<Action>,
}
#[derive(Debug, Args, Clone)]
struct ShadowArgs {
#[arg(long = "no-shadow")]
no_shadow: bool,
#[arg(long = "radius", value_name = "PX")]
radius: Option<u32>,
#[arg(long = "shadow-offset", value_name = "X,Y", value_parser = parse_offset)]
offset: Option<(i32, i32)>,
#[arg(long = "shadow-alpha", value_name = "0-255")]
alpha: Option<u8>,
#[arg(long = "shadow-spread", value_name = "PX")]
spread: Option<u32>,
#[arg(long = "shadow-blur", value_name = "PX")]
blur: Option<u32>,
}
fn parse_color(s: &str) -> std::result::Result<u32, String> {
let s = s.trim_start_matches('#');
let s: std::borrow::Cow<str> = if s.len() == 6 {
format!("{s}ff").into()
} else {
s.into()
};
if s.len() != 8 {
return Err(format!("expected RRGGBBAA (6 or 8 hex digits), got '{s}'"));
}
u32::from_str_radix(&s, 16).map_err(|e| format!("invalid color '{s}': {e}"))
}
fn parse_offset(s: &str) -> std::result::Result<(i32, i32), String> {
let parts: Vec<&str> = s.splitn(2, ',').collect();
if parts.len() != 2 {
return Err(format!("expected X,Y e.g. '-20,-20', got '{s}'"));
}
let x = parts[0]
.trim()
.parse::<i32>()
.map_err(|e| format!("bad X: {e}"))?;
let y = parts[1]
.trim()
.parse::<i32>()
.map_err(|e| format!("bad Y: {e}"))?;
Ok((x, y))
}
impl ShadowArgs {
/// Resolve into a ShadowOptions, pulling the corner radius from Hyprland
/// IPC if not explicitly provided.
fn resolve(&self) -> Option<ShadowOptions> {
if self.no_shadow {
return None;
}
let corner_radius = self.radius.unwrap_or_else(|| {
let bs = hyprland::border_size().unwrap_or(0);
let rnd = hyprland::rounding().unwrap_or(8);
(rnd + bs) as u32
});
Some(ShadowOptions {
corner_radius,
offset: self.offset.unwrap_or((-20, -20)),
shadow_alpha: self.alpha.unwrap_or(150),
spread: self.spread.unwrap_or(26),
blur_radius: self.blur.unwrap_or(5),
})
}
}
#[derive(Debug, Subcommand)]
enum Action {
Copy {
#[arg(value_enum, default_value_t = Subject::Screen)]
subject: Subject,
},
Save {
#[arg(value_enum, default_value_t = Subject::Screen)]
subject: Subject,
/// Output file path, or '-' for stdout.
file: Option<String>,
},
Copysave {
#[arg(value_enum, default_value_t = Subject::Screen)]
subject: Subject,
#[arg(short = 'o', long = "output")]
output: Option<String>,
#[arg(hide = true)]
file: Option<String>,
},
Edit {
#[arg(value_enum, default_value_t = Subject::Screen)]
subject: Subject,
/// Output file path (defaults to /tmp/<timestamp>.png).
file: Option<String>,
},
Check,
}
#[derive(Debug, Clone, ValueEnum)]
enum Subject {
Active,
Screen,
Output,
Area,
Region,
}
fn main() {
let cli = Cli::parse();
let action = match cli.action {
Some(a) => a,
None => {
print_usage();
process::exit(0);
}
};
let ctx = Context {
notify: cli.notify,
cursor: cli.cursor,
freeze: cli.freeze,
wait_secs: cli.wait,
scale: cli.scale,
hint_color: cli.hint_color,
shadow: cli.shadow,
};
if let Err(e) = run(action, ctx) {
match e {
BlastError::SelectionCancelled => process::exit(1),
other => {
eprintln!("blast: error: {other}");
process::exit(2);
}
}
}
}
fn print_usage() {
println!("Usage:");
println!(
" blast [FLAGS] (copy|save|copysave|edit) [active|screen|output|area|region] [FILE|-]"
);
println!(" blast check");
println!();
println!("Flags:");
println!(" -n, --notify Desktop notification on completion");
println!(" -c, --cursor Include cursor in screenshot");
println!(" -f, --freeze Freeze screen during area selection");
println!(" -w, --wait N Wait N seconds before capture");
println!(" -s, --scale SCALE Scale factor");
println!(" --hint-color RRGGBBAA Hint-box colour for area/region (e.g. ff550080)");
println!(" --no-shadow Disable drop shadow and corner rounding");
println!(" --radius px Corner radius (default: from Hyprland config or 0)");
println!(" --shadow-offset x,y Shadow offset (default: -20,-20)");
println!(" --shadow-alpha N Shadow opacity 0-255 (default: 150)");
println!(" --shadow-spread px Shadow spread (default: 26)");
println!(" --shadow-blur px Shadow blur radius (default: 5)");
}
struct Context {
notify: bool,
cursor: bool,
freeze: bool,
wait_secs: Option<u64>,
scale: Option<f64>,
hint_color: Option<u32>,
shadow: ShadowArgs,
}
impl Context {
fn capture_opts(&self) -> CaptureOptions {
CaptureOptions {
include_cursor: self.cursor,
scale: self.scale,
}
}
fn maybe_wait(&self) {
if let Some(secs) = self.wait_secs {
std::thread::sleep(std::time::Duration::from_secs(secs));
}
}
fn notify_ok(&self, summary: &str, body: Option<&str>, icon: Option<&str>) {
if self.notify {
let _ = notify::notify_ok(summary, body, icon);
}
}
fn notify_err(&self, summary: &str, body: Option<&str>) {
if self.notify {
let _ = notify::notify_error(summary, body);
} else {
eprintln!("blast: {}: {}", summary, body.unwrap_or(""));
}
}
/// Encode image to PNG, applying shadow/rounding if enabled.
fn finalize_png(&self, img: &DynamicImage) -> Result<Vec<u8>> {
match self.shadow.resolve() {
Some(opts) => shhh::apply_and_encode(img, &opts),
None => encode_png(img),
}
}
}
fn run(action: Action, ctx: Context) -> Result<()> {
match action {
Action::Check => run_check(),
Action::Copy { subject } => run_copy(subject, ctx),
Action::Save { subject, file } => run_save(subject, file, ctx),
Action::Copysave {
subject,
output,
file,
} => {
// --output takes precedence over the positional file arg.
run_copysave(subject, output.or(file), ctx)
}
Action::Edit { subject, file } => run_edit(subject, file, ctx),
}
}
fn run_check() -> Result<()> {
println!("Checking Hyprland IPC...");
match hyprland::active_window() {
Ok(w) => println!(" hyprland IPC: OK (active: {})", w.class),
Err(e) => println!(" hyprland IPC: FAIL ({e})"),
}
println!("Checking wlr-screencopy...");
match capture::capture_screen(&CaptureOptions::default()) {
Ok(_) => println!(" wlr-screencopy: OK"),
Err(e) => println!(" wlr-screencopy: FAIL ({e})"),
}
println!("Checking shhh image processing...");
{
use image::{DynamicImage, ImageBuffer, Rgba};
let img = DynamicImage::ImageRgba8(ImageBuffer::from_pixel(4, 4, Rgba([255u8, 0, 0, 255])));
match shhh::apply_effects(&img, &ShadowOptions::default()) {
Ok(_) => println!(" shhh: OK"),
Err(e) => println!(" shhh: FAIL ({e})"),
}
}
println!(" wl-clipboard-rs: OK (linked)");
println!(" notify-rust: OK (linked)");
println!(" freeze (layer-shell):OK (native)");
Ok(())
}
fn run_copy(subject: Subject, ctx: Context) -> Result<()> {
let (img, what) = capture_subject(&subject, &ctx)?;
let png = ctx.finalize_png(&img)?;
clipboard::copy_png(png).map_err(|e| {
ctx.notify_err("Clipboard error", Some(&e.to_string()));
e
})?;
ctx.notify_ok(&format!("{what} copied to clipboard"), None, None);
Ok(())
}
fn run_save(subject: Subject, file: Option<String>, ctx: Context) -> Result<()> {
let (img, what) = capture_subject(&subject, &ctx)?;
match file.as_deref() {
Some("-") => {
let png = ctx.finalize_png(&img)?;
io::stdout()
.lock()
.write_all(&png)
.map_err(BlastError::Io)?;
}
path => {
let dest = resolve_dest(path);
let png = ctx.finalize_png(&img)?;
std::fs::write(&dest, &png).map_err(BlastError::Io)?;
let name = dest.display().to_string();
ctx.notify_ok(&format!("Screenshot of {what}"), Some(&name), Some(&name));
println!("{name}");
}
}
Ok(())
}
fn run_copysave(subject: Subject, file: Option<String>, ctx: Context) -> Result<()> {
let (img, what) = capture_subject(&subject, &ctx)?;
let png = ctx.finalize_png(&img)?;
clipboard::copy_png(png.clone()).map_err(|e| {
ctx.notify_err("Clipboard error", Some(&e.to_string()));
e
})?;
let dest = resolve_dest(file.as_deref());
std::fs::write(&dest, &png).map_err(BlastError::Io)?;
let name = dest.display().to_string();
ctx.notify_ok(
&format!("{what} copied and saved"),
Some(&name),
Some(&name),
);
println!("{name}");
Ok(())
}
fn run_edit(subject: Subject, file: Option<String>, ctx: Context) -> Result<()> {
let (img, what) = capture_subject(&subject, &ctx)?;
let dest = file
.map(PathBuf::from)
.unwrap_or_else(paths::default_editor_path);
let png = ctx.finalize_png(&img)?;
std::fs::write(&dest, &png).map_err(BlastError::Io)?;
let name = dest.display().to_string();
ctx.notify_ok(
&format!("Screenshot of {what}"),
Some("Opening in image editor"),
Some(&name),
);
let editor = paths::resolve_editor();
std::process::Command::new(&editor)
.arg(&dest)
.spawn()
.map_err(|e| BlastError::Other(format!("spawn {editor}: {e}")))?;
println!("{name}");
Ok(())
}
fn capture_subject(subject: &Subject, ctx: &Context) -> Result<(DynamicImage, String)> {
match subject {
Subject::Screen => {
ctx.maybe_wait();
let img = capture::capture_screen(&ctx.capture_opts())?;
Ok((img, "Screen".into()))
}
Subject::Active => {
ctx.maybe_wait();
let win = hyprland::active_window()?;
let bs = hyprland::border_size().unwrap_or(0);
let geom = win.to_geometry(bs, 0);
let img = capture::capture_region(geom, &ctx.capture_opts())?;
Ok((img, format!("{} window", win.class)))
}
Subject::Output => {
ctx.maybe_wait();
let mon = hyprland::focused_monitor()?;
let img = capture::capture_output(&mon.name, &ctx.capture_opts())?;
Ok((img, mon.name.clone()))
}
Subject::Area => capture_area(ctx),
Subject::Region => capture_region_free(ctx),
}
}
fn capture_area(ctx: &Context) -> Result<(DynamicImage, String)> {
let mut freeze_guard = if ctx.freeze {
freeze::FreezeGuard::spawn()?
} else {
freeze::FreezeGuard::none()
};
let on_hyprland = std::env::var("HYPRLAND_INSTANCE_SIGNATURE").is_ok();
let geom = if on_hyprland {
hyprland::with_animations_disabled(|border_size| {
// Prefer Wayland-native geometry; fall back to Hyprland IPC.
let boxes = wayland_windows::visible_window_hints().unwrap_or_else(|| {
let bar = hyprland::get_option_int("plugin:hyprbars:bar_height").unwrap_or(0);
hyprland::visible_windows()
.map(|wins| {
wins.iter()
.map(|w| {
let g = w.to_geometry(border_size, bar);
select::HintBox {
x: g.x as i32,
y: g.y as i32,
w: g.w as i32,
h: g.h as i32,
label: w.class.clone(),
}
})
.collect()
})
.unwrap_or_default()
});
region::select_area_boxes(boxes, ctx.hint_color)
})?
} else {
let boxes = wayland_windows::visible_window_hints().unwrap_or_default();
region::select_area_boxes(boxes, ctx.hint_color)?
};
let img = capture::capture_region(geom, &ctx.capture_opts())?;
freeze_guard.kill();
Ok((img, "Area".into()))
}
fn capture_region_free(ctx: &Context) -> Result<(DynamicImage, String)> {
let mut freeze_guard = if ctx.freeze {
freeze::FreezeGuard::spawn()?
} else {
freeze::FreezeGuard::none()
};
let geom = region::select_free_region(ctx.hint_color)?;
let img = capture::capture_region(geom, &ctx.capture_opts())?;
freeze_guard.kill();
Ok((img, "Region".into()))
}
fn resolve_dest(path: Option<&str>) -> PathBuf {
path.map(PathBuf::from).unwrap_or_else(|| {
let prefix = hyprland::active_window()
.ok()
.map(|w| w.class)
.unwrap_or_default();
paths::default_save_path(Some(&prefix))
})
}

37
src/notify.rs Normal file
View file

@ -0,0 +1,37 @@
use notify_rust::{Notification, Urgency};
use crate::error::{BlastError, Result};
#[allow(dead_code)]
pub fn notify_ok(summary: &str, body: Option<&str>, icon: Option<&str>) -> Result<()> {
let mut n = Notification::new();
n.appname("blast").summary(summary).timeout(3000);
if let Some(b) = body {
n.body(b);
}
if let Some(i) = icon {
n.icon(i);
}
n.show().map_err(|e| BlastError::Notify(e.to_string()))?;
Ok(())
}
#[allow(dead_code)]
pub fn notify_error(summary: &str, body: Option<&str>) -> Result<()> {
let mut n = Notification::new();
n.appname("blast")
.summary(summary)
.urgency(Urgency::Critical)
.timeout(5000);
if let Some(b) = body {
n.body(b);
}
n.show().map_err(|e| BlastError::Notify(e.to_string()))?;
Ok(())
}

62
src/paths.rs Normal file
View file

@ -0,0 +1,62 @@
//! File path helpers: XDG screenshot dir, random names, editor tmp dir.
use std::{env, path::PathBuf};
use rand::{distributions::Alphanumeric, Rng};
/// Resolve the target directory for saving screenshots.
///
/// Priority: $XDG_SCREENSHOTS_DIR -> $XDG_PICTURES_DIR -> $HOME.
pub fn target_directory() -> PathBuf {
if let Ok(dir) = env::var("XDG_SCREENSHOTS_DIR") {
if !dir.is_empty() {
return PathBuf::from(dir);
}
}
if let Ok(dir) = env::var("XDG_PICTURES_DIR") {
if !dir.is_empty() {
return PathBuf::from(dir);
}
}
dirs::home_dir().unwrap_or_else(|| PathBuf::from("."))
}
#[allow(dead_code)]
pub fn tmp_editor_directory() -> PathBuf {
PathBuf::from("/tmp")
}
pub fn random_filename(prefix: Option<&str>) -> String {
let rand_part: String = rand::thread_rng()
.sample_iter(&Alphanumeric)
.take(13)
.map(char::from)
.collect();
match prefix {
Some(p) if !p.is_empty() => format!("{p}_{rand_part}.png"),
_ => format!("{rand_part}.png"),
}
}
pub fn default_save_path(prefix: Option<&str>) -> PathBuf {
target_directory().join(random_filename(prefix))
}
#[allow(dead_code)]
pub fn default_editor_path() -> PathBuf {
use std::time::{SystemTime, UNIX_EPOCH};
let ns = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
tmp_editor_directory().join(format!("{ns}.png"))
}
/// Resolve the editor binary.
///
/// Uses $BLAST_EDITOR if set, otherwise falls back to "gimp".
#[allow(dead_code)]
pub fn resolve_editor() -> String {
env::var("BLAST_EDITOR").unwrap_or_else(|_| "gimp".into())
}

43
src/region.rs Normal file
View file

@ -0,0 +1,43 @@
use crate::{
error::Result,
hyprland::{Geometry, WindowInfo},
select::{self, HintBox},
};
/// Area selection from a pre-built list of hint boxes (logical coords).
pub fn select_area_boxes(boxes: Vec<HintBox>, hint_rgba: Option<u32>) -> Result<Geometry> {
if boxes.is_empty() {
select::select_region(vec![], false, hint_rgba)
} else {
select::select_region(boxes, true, hint_rgba)
}
}
pub fn select_free_region(hint_rgba: Option<u32>) -> Result<Geometry> {
select::select_region(vec![], false, hint_rgba)
}
#[allow(dead_code)]
pub fn select_area(
windows: &[WindowInfo],
border_size: i64,
bar_height: i64,
hint_rgba: Option<u32>,
) -> Result<Geometry> {
if windows.is_empty() {
return select::select_region(vec![], false, hint_rgba);
}
let boxes = windows
.iter()
.map(|w| {
let g = w.to_geometry(border_size, bar_height);
HintBox {
x: g.x as i32,
y: g.y as i32,
w: g.w as i32,
h: g.h as i32,
label: w.class.clone(),
}
})
.collect();
select::select_region(boxes, true, hint_rgba)
}

873
src/select.rs Normal file
View file

@ -0,0 +1,873 @@
//! Native Wayland region/area selector.
use std::{
fs::File,
os::unix::io::{AsFd, FromRawFd},
};
use memmap2::{MmapMut, MmapOptions};
use wayland_client::{
delegate_noop,
globals::{registry_queue_init, GlobalListContents},
protocol::{
wl_buffer, wl_callback, wl_compositor, wl_keyboard, wl_output, wl_pointer, wl_region,
wl_registry, wl_seat, wl_shm, wl_shm_pool, wl_surface,
},
Connection, Dispatch, EventQueue, Proxy, QueueHandle, WEnum,
};
use wayland_protocols::wp::cursor_shape::v1::client::{
wp_cursor_shape_device_v1::{Shape as CursorShape, WpCursorShapeDeviceV1},
wp_cursor_shape_manager_v1::WpCursorShapeManagerV1,
};
use wayland_protocols_wlr::layer_shell::v1::client::{
zwlr_layer_shell_v1::ZwlrLayerShellV1,
zwlr_layer_surface_v1::{self, ZwlrLayerSurfaceV1},
};
use crate::{
error::{BlastError, Result},
hyprland::Geometry,
};
const C_BG: [u8; 4] = [0x26, 0x26, 0x26, 0x26];
const C_SEL: [u8; 4] = [0x00, 0x00, 0x00, 0x00];
const C_BDR: [u8; 4] = [0x00, 0x00, 0x00, 0xFF];
const DEFAULT_HINT: [u8; 4] = [0x40, 0x40, 0x40, 0x40];
const DEFAULT_HOV: [u8; 4] = [0x80, 0x80, 0x80, 0x80];
fn rgba_to_bgra_premult(rgba: u32) -> [u8; 4] {
let r = ((rgba >> 24) & 0xFF) as u8;
let g = ((rgba >> 16) & 0xFF) as u8;
let b = ((rgba >> 8) & 0xFF) as u8;
let a = (rgba & 0xFF) as u8;
let pm = |c: u8| -> u8 { ((c as u32 * a as u32) / 255) as u8 };
[pm(b), pm(g), pm(r), a]
}
fn derive_hover(rgba: u32) -> [u8; 4] {
let r = ((rgba >> 24) & 0xFF) as u32;
let g = ((rgba >> 16) & 0xFF) as u32;
let b = ((rgba >> 8) & 0xFF) as u32;
let a = ((rgba & 0xFF) as u32 * 2).min(255);
let pm = |c: u32| -> u8 { ((c * a) / 255) as u8 };
[pm(b), pm(g), pm(r), a as u8]
}
const BDR: i32 = 2;
pub struct HintBox {
pub x: i32,
pub y: i32,
pub w: i32,
pub h: i32,
pub label: String,
}
struct PendingOut {
wl: wl_output::WlOutput,
name: Option<String>,
ox: i32,
oy: i32,
scale: i32,
}
struct Surf {
wl: wl_surface::WlSurface,
layer: ZwlrLayerSurfaceV1,
configured: bool,
lx: i32,
ly: i32,
lw: i32,
lh: i32,
scale: i32,
pw: i32,
ph: i32,
stride: usize,
buf_size: usize,
_file: Option<File>,
_pool: Option<wl_shm_pool::WlShmPool>,
// Two buffers for double-buffering
bufs: [Option<wl_buffer::WlBuffer>; 2],
mmap: Option<MmapMut>,
buf_busy: [bool; 2],
// per-surface render state
frame_pending: bool,
needs_repaint: bool,
}
struct St {
conn: Connection,
qh: QueueHandle<St>,
queue: Option<EventQueue<St>>,
compositor: Option<wl_compositor::WlCompositor>,
shm: Option<wl_shm::WlShm>,
shell: Option<ZwlrLayerShellV1>,
seat: Option<wl_seat::WlSeat>,
pointer: Option<wl_pointer::WlPointer>,
keyboard: Option<wl_keyboard::WlKeyboard>,
cursor_shape: Option<WpCursorShapeManagerV1>,
pending: Vec<PendingOut>,
surfs: Vec<Surf>,
configured: usize, // count of configure events received
on_surf: Option<usize>, // index into surfs for current pointer output
cx: i32,
cy: i32, // cursor position (global logical)
anchor_x: i32,
anchor_y: i32,
dragging: bool,
boxes: Vec<HintBox>,
hover: Option<usize>,
restrict: bool,
c_hint: [u8; 4],
c_hov: [u8; 4],
running: bool,
result: Option<Geometry>,
}
fn fill(data: &mut [u8], stride: usize, x0: i32, y0: i32, x1: i32, y1: i32, c: [u8; 4]) {
for y in y0..y1 {
let row = y as usize * stride;
for x in x0..x1 {
let i = row + x as usize * 4;
data[i..i + 4].copy_from_slice(&c);
}
}
}
fn draw_border(
data: &mut [u8],
stride: usize,
pw: i32,
ph: i32,
x0: i32,
y0: i32,
x1: i32,
y1: i32,
bw: i32,
c: [u8; 4],
) {
let strips = [
(x0.max(0), y0.max(0), x1.min(pw), (y0 + bw).min(y1).min(ph)),
(x0.max(0), (y1 - bw).max(y0).max(0), x1.min(pw), y1.min(ph)),
(
x0.max(0),
(y0 + bw).max(0),
(x0 + bw).min(x1).min(pw),
(y1 - bw).min(ph),
),
(
(x1 - bw).max(x0).max(0),
(y0 + bw).max(0),
x1.min(pw),
(y1 - bw).min(ph),
),
];
for (ax0, ay0, ax1, ay1) in strips {
if ax0 < ax1 && ay0 < ay1 {
fill(data, stride, ax0, ay0, ax1, ay1, c);
}
}
}
fn rect_union(
a: Option<(i32, i32, i32, i32)>,
b: Option<(i32, i32, i32, i32)>,
) -> Option<(i32, i32, i32, i32)> {
match (a, b) {
(None, None) => None,
(Some(r), None) | (None, Some(r)) => Some(r),
(Some(a), Some(b)) => {
let x0 = a.0.min(b.0);
let y0 = a.1.min(b.1);
let x1 = (a.0 + a.2).max(b.0 + b.2);
let y1 = (a.1 + a.3).max(b.1 + b.3);
Some((x0, y0, x1 - x0, y1 - y0))
}
}
}
fn expand(r: Option<(i32, i32, i32, i32)>, pad: i32) -> Option<(i32, i32, i32, i32)> {
r.map(|(x, y, w, h)| (x - pad, y - pad, w + 2 * pad, h + 2 * pad))
}
fn alloc_shm(size: usize) -> std::io::Result<File> {
#[cfg(target_os = "linux")]
unsafe {
let fd = libc::memfd_create(b"blast-sel\0".as_ptr().cast(), libc::MFD_CLOEXEC);
if fd >= 0 {
let f = File::from_raw_fd(fd);
f.set_len(size as u64)?;
return Ok(f);
}
}
let f = tempfile::tempfile()?;
f.set_len(size as u64)?;
Ok(f)
}
impl St {
fn roundtrip(&mut self) -> Result<()> {
let mut q = self.queue.take().unwrap();
let r = q
.roundtrip(self)
.map_err(|e| BlastError::Selection(format!("roundtrip: {e}")));
self.queue = Some(q);
r?;
Ok(())
}
fn dispatch_once(&mut self) -> Result<()> {
let mut q = self.queue.take().unwrap();
let r = q
.blocking_dispatch(self)
.map_err(|e| BlastError::Selection(format!("dispatch: {e}")));
self.queue = Some(q);
r?;
Ok(())
}
fn flush(&self) {
if let Some(q) = &self.queue {
let _ = q.flush();
}
}
fn create_surfaces(&mut self) -> Result<()> {
use wayland_protocols_wlr::layer_shell::v1::client::{
zwlr_layer_shell_v1::Layer, zwlr_layer_surface_v1::Anchor,
};
let qh = self.qh.clone();
let mut surf_idx = 0usize;
for p in &self.pending {
let (lx, ly, scale) = (p.ox, p.oy, p.scale);
let wl_surf = self.compositor.as_ref().unwrap().create_surface(&qh, ());
let layer = self.shell.as_ref().unwrap().get_layer_surface(
&wl_surf,
Some(&p.wl),
Layer::Overlay,
"blast-select".to_string(),
&qh,
surf_idx,
);
layer.set_size(0, 0);
layer.set_exclusive_zone(-1);
layer.set_anchor(Anchor::Top | Anchor::Bottom | Anchor::Left | Anchor::Right);
// Exclusive keyboard focus so ESC events are delivered to us.
layer.set_keyboard_interactivity(
zwlr_layer_surface_v1::KeyboardInteractivity::Exclusive,
);
wl_surf.set_buffer_scale(scale);
wl_surf.commit();
self.surfs.push(Surf {
wl: wl_surf,
layer,
configured: false,
lx,
ly,
lw: 0,
lh: 0,
scale,
pw: 0,
ph: 0,
stride: 0,
buf_size: 0,
_file: None,
_pool: None,
bufs: [None, None],
mmap: None,
buf_busy: [false, false],
frame_pending: false,
needs_repaint: false,
});
surf_idx += 1;
}
Ok(())
}
fn curr_sel(&self) -> Option<(i32, i32, i32, i32)> {
if !self.dragging {
return None;
}
let x0 = self.anchor_x.min(self.cx);
let y0 = self.anchor_y.min(self.cy);
let x1 = self.anchor_x.max(self.cx) + 1;
let y1 = self.anchor_y.max(self.cy) + 1;
Some((x0, y0, x1 - x0, y1 - y0))
}
fn update_hover(&mut self) {
if !self.restrict {
return;
}
let (cx, cy) = (self.cx, self.cy);
self.hover = self
.boxes
.iter()
.position(|b| cx >= b.x && cx < b.x + b.w && cy >= b.y && cy < b.y + b.h);
}
fn mark_all_repaint(&mut self) {
for s in &mut self.surfs {
s.needs_repaint = true;
}
}
/// Request a frame callback for surface idx if one is not already pending.
/// An empty commit is used to schedule the callback.
fn request_frame(&mut self, idx: usize) {
let s = &mut self.surfs[idx];
if s.frame_pending {
return;
}
let qh = self.qh.clone();
let s = &mut self.surfs[idx];
s.wl.frame(&qh, idx);
s.wl.commit();
s.frame_pending = true;
}
fn render_surf(&mut self, idx: usize) {
let sel = self.curr_sel();
let hover = if self.restrict { self.hover } else { None };
// Pick a free buffer slot
let buf_slot = {
let s = &self.surfs[idx];
if !s.configured || s.mmap.is_none() {
return;
}
if !s.buf_busy[0] {
0
} else if !s.buf_busy[1] {
1
} else {
return;
} // both in use compositor hasn't released either yet
};
let s = &self.surfs[idx];
let lx = s.lx;
let ly = s.ly;
let scale = s.scale;
let pw = s.pw;
let ph = s.ph;
let stride = s.stride;
let buf_size = s.buf_size;
let c_hint = self.c_hint;
let c_hov = self.c_hov;
let box_data: Vec<(i32, i32, i32, i32, bool)> = self
.boxes
.iter()
.enumerate()
.map(|(i, b)| (b.x, b.y, b.w, b.h, hover == Some(i)))
.collect();
let s = &mut self.surfs[idx];
let offset = buf_slot * buf_size;
let data = &mut s.mmap.as_mut().unwrap()[offset..offset + buf_size];
// Always repaint the full surface with double-buffering the "other" slot
// may have stale content, and a full repaint keeps both slots consistent.
fill(data, stride, 0, 0, pw, ph, C_BG);
for (bx, by, bw, bh, is_hov) in &box_data {
let bx0 = ((bx - lx) * scale).max(0).min(pw);
let by0 = ((by - ly) * scale).max(0).min(ph);
let bx1 = ((bx + bw - lx) * scale).max(0).min(pw);
let by1 = ((by + bh - ly) * scale).max(0).min(ph);
if bx0 < bx1 && by0 < by1 {
fill(
data,
stride,
bx0,
by0,
bx1,
by1,
if *is_hov { c_hov } else { c_hint },
);
if *is_hov {
draw_border(data, stride, pw, ph, bx0, by0, bx1, by1, BDR * scale, C_BDR);
}
}
}
if let Some((gx, gy, gw, gh)) = sel {
let x0 = ((gx - lx) * scale).max(0).min(pw);
let y0 = ((gy - ly) * scale).max(0).min(ph);
let x1 = ((gx + gw - lx) * scale).max(0).min(pw);
let y1 = ((gy + gh - ly) * scale).max(0).min(ph);
if x0 < x1 && y0 < y1 {
fill(data, stride, x0, y0, x1, y1, C_SEL);
draw_border(data, stride, pw, ph, x0, y0, x1, y1, BDR * scale, C_BDR);
}
}
// Attach buffer, damage the full surface, and commit.
s.wl.attach(Some(s.bufs[buf_slot].as_ref().unwrap()), 0, 0);
s.wl.damage_buffer(0, 0, pw, ph);
s.wl.commit();
s.buf_busy[buf_slot] = true;
s.needs_repaint = false;
}
}
impl Dispatch<wl_registry::WlRegistry, GlobalListContents> for St {
fn event(
_: &mut Self,
_: &wl_registry::WlRegistry,
_: wl_registry::Event,
_: &GlobalListContents,
_: &Connection,
_: &QueueHandle<Self>,
) {
}
}
impl Dispatch<wl_output::WlOutput, ()> for St {
fn event(
state: &mut Self,
wl: &wl_output::WlOutput,
event: wl_output::Event,
_: &(),
_: &Connection,
_: &QueueHandle<Self>,
) {
let entry = state.pending.iter_mut().find(|p| p.wl.id() == wl.id());
let Some(p) = entry else { return };
match event {
wl_output::Event::Name { name } => p.name = Some(name),
wl_output::Event::Geometry { x, y, .. } => {
p.ox = x;
p.oy = y;
}
wl_output::Event::Scale { factor } => p.scale = factor,
_ => {}
}
}
}
impl Dispatch<wl_seat::WlSeat, ()> for St {
fn event(
state: &mut Self,
seat: &wl_seat::WlSeat,
event: wl_seat::Event,
_: &(),
_: &Connection,
qh: &QueueHandle<Self>,
) {
if let wl_seat::Event::Capabilities {
capabilities: WEnum::Value(caps),
} = event
{
if caps.contains(wl_seat::Capability::Pointer) && state.pointer.is_none() {
state.pointer = Some(seat.get_pointer(qh, ()));
}
if caps.contains(wl_seat::Capability::Keyboard) && state.keyboard.is_none() {
state.keyboard = Some(seat.get_keyboard(qh, ()));
}
}
}
}
impl Dispatch<wl_pointer::WlPointer, ()> for St {
fn event(
state: &mut Self,
pointer: &wl_pointer::WlPointer,
event: wl_pointer::Event,
_: &(),
_: &Connection,
qh: &QueueHandle<Self>,
) {
match event {
wl_pointer::Event::Enter {
serial,
surface,
surface_x,
surface_y,
} => {
let idx = state.surfs.iter().position(|s| s.wl.id() == surface.id());
state.on_surf = idx;
if let Some(i) = idx {
let s = &state.surfs[i];
state.cx = s.lx + surface_x as i32;
state.cy = s.ly + surface_y as i32;
}
if let Some(mgr) = &state.cursor_shape {
let device = mgr.get_pointer(pointer, qh, ());
device.set_shape(serial, CursorShape::Crosshair);
}
state.update_hover();
state.mark_all_repaint();
let n = state.surfs.len();
for idx in 0..n {
if state.surfs[idx].needs_repaint && !state.surfs[idx].frame_pending {
state.request_frame(idx);
}
}
}
wl_pointer::Event::Leave { .. } => {
state.on_surf = None;
}
wl_pointer::Event::Motion {
surface_x,
surface_y,
..
} => {
if let Some(i) = state.on_surf {
let s = &state.surfs[i];
state.cx = s.lx + surface_x as i32;
state.cy = s.ly + surface_y as i32;
}
state.update_hover();
state.mark_all_repaint();
let n = state.surfs.len();
for idx in 0..n {
if state.surfs[idx].needs_repaint && !state.surfs[idx].frame_pending {
state.request_frame(idx);
}
}
}
wl_pointer::Event::Button {
button,
state: WEnum::Value(btn_state),
..
} => {
if button != 0x110 {
return;
} // BTN_LEFT = 0x110
match btn_state {
wl_pointer::ButtonState::Pressed => {
if state.restrict {
// handled on release
} else {
state.dragging = true;
state.anchor_x = state.cx;
state.anchor_y = state.cy;
}
}
wl_pointer::ButtonState::Released => {
if state.restrict {
if let Some(hi) = state.hover {
let b = &state.boxes[hi];
state.result = Some(Geometry {
x: b.x as i64,
y: b.y as i64,
w: b.w as i64,
h: b.h as i64,
});
state.running = false;
}
} else if state.dragging {
if let Some((x, y, w, h)) = state.curr_sel() {
if w > 0 && h > 0 {
state.result = Some(Geometry {
x: x as i64,
y: y as i64,
w: w as i64,
h: h as i64,
});
state.running = false;
}
}
state.dragging = false;
}
}
_ => {}
}
}
_ => {}
}
}
}
impl Dispatch<wl_keyboard::WlKeyboard, ()> for St {
fn event(
state: &mut Self,
_: &wl_keyboard::WlKeyboard,
event: wl_keyboard::Event,
_: &(),
_: &Connection,
_: &QueueHandle<Self>,
) {
if let wl_keyboard::Event::Key {
key,
state: WEnum::Value(wl_keyboard::KeyState::Pressed),
..
} = event
{
if key == 1 {
// ESC scancode
state.running = false;
}
}
}
}
impl Dispatch<ZwlrLayerSurfaceV1, usize> for St {
fn event(
state: &mut Self,
layer: &ZwlrLayerSurfaceV1,
event: zwlr_layer_surface_v1::Event,
idx: &usize,
_: &Connection,
qh: &QueueHandle<Self>,
) {
let idx = *idx;
if let zwlr_layer_surface_v1::Event::Configure {
serial,
width,
height,
} = event
{
layer.ack_configure(serial);
let s = &mut state.surfs[idx];
if s.configured {
return;
} // ignore reconfigures
s.lw = width as i32;
s.lh = height as i32;
s.pw = s.lw * s.scale;
s.ph = s.lh * s.scale;
s.stride = s.pw as usize * 4;
let buf_size = s.stride * s.ph as usize;
s.buf_size = buf_size;
let file = match alloc_shm(2 * buf_size) {
Ok(f) => f,
Err(e) => {
eprintln!("blast: select: alloc_shm: {e}");
return;
}
};
let mmap = match unsafe { MmapOptions::new().len(2 * buf_size).map_mut(&file) } {
Ok(m) => m,
Err(e) => {
eprintln!("blast: select: mmap: {e}");
return;
}
};
let shm = state.shm.as_ref().unwrap();
let pool = shm.create_pool(file.as_fd(), (2 * buf_size) as i32, qh, ());
// User-data encodes both surface index and buffer slot: idx*2+slot.
let buf0 = pool.create_buffer(
0,
s.pw,
s.ph,
s.stride as i32,
wl_shm::Format::Argb8888,
qh,
idx * 2,
);
let buf1 = pool.create_buffer(
buf_size as i32,
s.pw,
s.ph,
s.stride as i32,
wl_shm::Format::Argb8888,
qh,
idx * 2 + 1,
);
let s = &mut state.surfs[idx];
s._file = Some(file);
s._pool = Some(pool);
s.bufs = [Some(buf0), Some(buf1)];
s.mmap = Some(mmap);
s.configured = true;
s.needs_repaint = true;
state.configured += 1;
}
}
}
impl Dispatch<wl_buffer::WlBuffer, usize> for St {
fn event(
state: &mut Self,
_: &wl_buffer::WlBuffer,
event: wl_buffer::Event,
slot: &usize,
_: &Connection,
_: &QueueHandle<Self>,
) {
if let wl_buffer::Event::Release = event {
let surf_idx = slot / 2;
let buf_slot = slot % 2;
state.surfs[surf_idx].buf_busy[buf_slot] = false;
}
}
}
impl Dispatch<wl_callback::WlCallback, usize> for St {
fn event(
state: &mut Self,
_: &wl_callback::WlCallback,
_: wl_callback::Event,
idx: &usize,
_: &Connection,
_: &QueueHandle<Self>,
) {
let idx = *idx;
state.surfs[idx].frame_pending = false;
if state.surfs[idx].needs_repaint {
state.render_surf(idx);
// If still needs another frame (e.g. buf was busy), re-request.
if state.surfs[idx].needs_repaint {
state.request_frame(idx);
}
}
}
}
impl Dispatch<wl_shm::WlShm, ()> for St {
fn event(
_: &mut Self,
_: &wl_shm::WlShm,
_: wl_shm::Event,
_: &(),
_: &Connection,
_: &QueueHandle<Self>,
) {
}
}
delegate_noop!(St: ignore wl_compositor::WlCompositor);
delegate_noop!(St: ignore wl_surface::WlSurface);
delegate_noop!(St: ignore wl_shm_pool::WlShmPool);
delegate_noop!(St: ignore ZwlrLayerShellV1);
delegate_noop!(St: ignore wl_region::WlRegion);
delegate_noop!(St: ignore WpCursorShapeManagerV1);
delegate_noop!(St: ignore WpCursorShapeDeviceV1);
pub fn select_region(
boxes: Vec<HintBox>,
restrict: bool,
hint_rgba: Option<u32>,
) -> Result<Geometry> {
let conn =
Connection::connect_to_env().map_err(|e| BlastError::Selection(format!("connect: {e}")))?;
let (globals, queue) = registry_queue_init::<St>(&conn)
.map_err(|e| BlastError::Selection(format!("registry: {e}")))?;
let qh = queue.handle();
let compositor = globals
.bind::<wl_compositor::WlCompositor, _, _>(&qh, 4..=6, ())
.map_err(|e| BlastError::Selection(format!("compositor: {e}")))?;
let shm = globals
.bind::<wl_shm::WlShm, _, _>(&qh, 1..=1, ())
.map_err(|e| BlastError::Selection(format!("shm: {e}")))?;
let shell = globals
.bind::<ZwlrLayerShellV1, _, _>(&qh, 1..=4, ())
.map_err(|e| BlastError::Selection(format!("layer_shell: {e}")))?;
let seat = globals
.bind::<wl_seat::WlSeat, _, _>(&qh, 1..=8, ())
.map_err(|e| BlastError::Selection(format!("seat: {e}")))?;
let pending: Vec<PendingOut> = globals
.contents()
.clone_list()
.iter()
.filter(|g| g.interface == "wl_output")
.map(|g| PendingOut {
wl: globals.registry().bind::<wl_output::WlOutput, _, _>(
g.name,
g.version.min(4),
&qh,
(),
),
name: None,
ox: 0,
oy: 0,
scale: 1,
})
.collect();
let cursor_shape = globals
.bind::<WpCursorShapeManagerV1, _, _>(&qh, 1..=1, ())
.ok();
let (c_hint, c_hov) = match hint_rgba {
Some(rgba) => (rgba_to_bgra_premult(rgba), derive_hover(rgba)),
None => (DEFAULT_HINT, DEFAULT_HOV),
};
let mut st = St {
conn,
qh,
queue: Some(queue),
compositor: Some(compositor),
shm: Some(shm),
shell: Some(shell),
seat: Some(seat),
pointer: None,
keyboard: None,
cursor_shape,
pending,
surfs: Vec::new(),
configured: 0,
on_surf: None,
cx: 0,
cy: 0,
anchor_x: 0,
anchor_y: 0,
dragging: false,
boxes,
hover: None,
restrict,
c_hint,
c_hov,
running: true,
result: None,
};
st.roundtrip()?;
st.roundtrip()?; // second pass to get pointer/keyboard from capabilities
st.create_surfaces()?;
let expected = st.surfs.len();
while st.configured < expected {
st.dispatch_once()?;
}
for idx in 0..st.surfs.len() {
st.render_surf(idx);
st.request_frame(idx);
}
st.flush();
while st.running {
st.dispatch_once()?;
}
for s in &st.surfs {
s.layer.destroy();
s.wl.destroy();
}
st.roundtrip()?;
// Give the compositor one frame period to repaint without the overlay.
std::thread::sleep(std::time::Duration::from_millis(32));
st.roundtrip()?;
st.result.ok_or(BlastError::SelectionCancelled)
}

205
src/shhh.rs Normal file
View file

@ -0,0 +1,205 @@
use image::{imageops, DynamicImage, GenericImageView, ImageBuffer, Rgba};
use png::{BitDepth, ColorType, Encoder};
use crate::error::{BlastError, Result};
#[derive(Debug, Clone)]
pub struct ShadowOptions {
pub corner_radius: u32,
pub offset: (i32, i32),
pub shadow_alpha: u8,
pub spread: u32,
pub blur_radius: u32,
}
impl Default for ShadowOptions {
fn default() -> Self {
Self {
corner_radius: 8,
offset: (-20, -20),
shadow_alpha: 150,
spread: 26,
blur_radius: 5,
}
}
}
/// Process a DynamicImage: round corners then add drop shadow.
pub fn apply_effects(img: &DynamicImage, opts: &ShadowOptions) -> Result<DynamicImage> {
let rounded = round_corners(img, opts.corner_radius);
add_rounded_drop_shadow(
&rounded,
opts.offset.0,
opts.offset.1,
opts.blur_radius,
opts.spread,
opts.shadow_alpha,
)
}
pub fn encode_png(img: &DynamicImage) -> Result<Vec<u8>> {
let rgba = img.to_rgba8();
let (width, height) = rgba.dimensions();
let mut out: Vec<u8> = Vec::new();
{
let mut encoder = Encoder::new(&mut out, width, height);
encoder.set_color(ColorType::Rgba);
encoder.set_depth(BitDepth::Eight);
let mut writer = encoder
.write_header()
.map_err(|e| BlastError::Image(e.to_string()))?;
writer
.write_image_data(rgba.as_raw())
.map_err(|e| BlastError::Image(e.to_string()))?;
}
Ok(out)
}
pub fn apply_and_encode(img: &DynamicImage, opts: &ShadowOptions) -> Result<Vec<u8>> {
let result = apply_effects(img, opts)?;
encode_png(&result)
}
/// Process raw PNG bytes
// pub fn process_png_bytes(input: &[u8], opts: &ShadowOptions) -> Result<Vec<u8>> {
// use image::ImageReader;
// let reader = ImageReader::new(std::io::Cursor::new(input))
// .with_guessed_format()
// .map_err(|e| BlastError::Image(format!("format detection: {e}")))?;
// let img = reader
// .decode()
// .map_err(|e| BlastError::Image(e.to_string()))?;
// let result = apply_effects(&img, opts)?;
// encode_png(&result)
// }
// image processing, logic from shhh
pub fn round_corners(img: &DynamicImage, radius: u32) -> DynamicImage {
let (width, height) = img.dimensions();
let mut rounded: ImageBuffer<Rgba<u8>, Vec<u8>> = ImageBuffer::new(width, height);
let r = radius as f32;
for (x, y, pixel) in img.to_rgba8().enumerate_pixels() {
let corner: Option<(f32, f32)> = if x < radius && y < radius {
Some((r - x as f32, r - y as f32))
} else if x >= width - radius && y < radius {
Some((x as f32 - (width as f32 - r - 1.0), r - y as f32))
} else if x < radius && y >= height - radius {
Some((r - x as f32, y as f32 - (height as f32 - r - 1.0)))
} else if x >= width - radius && y >= height - radius {
Some((
x as f32 - (width as f32 - r - 1.0),
y as f32 - (height as f32 - r - 1.0),
))
} else {
None
};
match corner {
None => rounded.put_pixel(x, y, *pixel),
Some((dx, dy)) => {
let dist = (dx * dx + dy * dy).sqrt();
if dist <= r {
rounded.put_pixel(x, y, *pixel);
} else {
let alpha = ((r + 1.0 - dist).max(0.0) * 255.0) as u8;
rounded.put_pixel(
x,
y,
Rgba([pixel[0], pixel[1], pixel[2], alpha.min(pixel[3])]),
);
}
}
}
}
DynamicImage::ImageRgba8(rounded)
}
pub fn add_rounded_drop_shadow(
img: &DynamicImage,
offset_x: i32,
offset_y: i32,
blur_radius: u32,
spread: u32,
shadow_alpha: u8,
) -> Result<DynamicImage> {
let (width, height) = img.dimensions();
let padding = spread + blur_radius * 2;
let total_w = (width as i32 + offset_x.abs() + padding as i32 * 2) as u32;
let total_h = (height as i32 + offset_y.abs() + padding as i32 * 2) as u32;
let mut output: ImageBuffer<Rgba<u8>, Vec<u8>> = ImageBuffer::new(total_w, total_h);
let shadow = create_shadow(img, blur_radius, spread, shadow_alpha);
let shadow_x: i64 = if offset_x >= 0 {
padding as i64
} else {
padding as i64 + offset_x as i64
};
let shadow_y: i64 = if offset_y >= 0 {
padding as i64
} else {
padding as i64 + offset_y as i64
};
imageops::overlay(&mut output, &shadow, shadow_x, shadow_y);
let image_x: i64 = if offset_x >= 0 {
padding as i64 + offset_x as i64
} else {
padding as i64
};
let image_y: i64 = if offset_y >= 0 {
padding as i64 + offset_y as i64
} else {
padding as i64
};
imageops::overlay(&mut output, img, image_x, image_y);
Ok(DynamicImage::ImageRgba8(output))
}
fn create_shadow(
img: &DynamicImage,
blur_radius: u32,
spread: u32,
shadow_alpha: u8,
) -> DynamicImage {
let (width, height) = img.dimensions();
let padding = spread + blur_radius * 2;
let new_w = width + padding * 2;
let new_h = height + padding * 2;
let mut shadow: ImageBuffer<Rgba<u8>, Vec<u8>> = ImageBuffer::new(new_w, new_h);
imageops::overlay(&mut shadow, &img.to_rgba8(), padding as i64, padding as i64);
for pixel in shadow.pixels_mut() {
let alpha = pixel[3] as f32 / 255.0 * shadow_alpha as f32;
*pixel = Rgba([0, 0, 0, alpha as u8]);
}
let adjusted_blur = blur_radius + (spread as f32 / 2.0) as u32;
let blurred = imageops::blur(&shadow, adjusted_blur as f32);
let mut cleaned: ImageBuffer<Rgba<u8>, Vec<u8>> = ImageBuffer::new(new_w, new_h);
for (x, y, pixel) in blurred.enumerate_pixels() {
if pixel[3] > 0 {
let factor = (pixel[3] as f32 / 255.0).powf(0.5);
cleaned.put_pixel(
x,
y,
Rgba([
(pixel[0] as f32 * factor) as u8,
(pixel[1] as f32 * factor) as u8,
(pixel[2] as f32 * factor) as u8,
(pixel[3] as f32 * factor) as u8,
]),
);
}
}
DynamicImage::ImageRgba8(cleaned)
}

363
src/wayland_windows.rs Normal file
View file

@ -0,0 +1,363 @@
//! Window geometry enumeration.
//!
//! Uses ext_foreign_toplevel_list_v1 + the custom
//! xx_foreign_toplevel_geometry_tracking_v1 protocol to enumerate visible
//! windows and their logical screen positions without Hyprland IPC.
//!
//! Returns None if either protocol is not advertised by the compositor
use std::collections::HashMap;
use wayland_client::{
delegate_noop, event_created_child,
globals::{registry_queue_init, GlobalListContents},
protocol::{wl_output, wl_registry},
Connection, Dispatch, EventQueue, Proxy, QueueHandle, WEnum,
};
use wayland_protocols::ext::foreign_toplevel_list::v1::client::{
ext_foreign_toplevel_handle_v1::{self, ExtForeignToplevelHandleV1},
ext_foreign_toplevel_list_v1::{self, ExtForeignToplevelListV1},
};
use wayland_protocols::xdg::xdg_output::zv1::client::{
zxdg_output_manager_v1::ZxdgOutputManagerV1,
zxdg_output_v1::{self, ZxdgOutputV1},
};
use crate::select::HintBox;
// Protocol bindings generated from xx_foreign_toplevel_geometry_tracking_v1.xml (yet to be named, will not use ext namespace most
// likely but we will use as a POC)
mod protocol {
#![allow(
dead_code,
non_camel_case_types,
unused_unsafe,
unused_variables,
non_upper_case_globals,
non_snake_case,
unused_imports,
clippy::all
)]
pub mod client {
use wayland_client;
use wayland_client::protocol::*;
use wayland_protocols::ext::foreign_toplevel_list::v1::client::*;
pub mod __interfaces {
use wayland_client::protocol::__interfaces::*;
use wayland_protocols::ext::foreign_toplevel_list::v1::client::__interfaces::*;
wayland_scanner::generate_interfaces!("protocols/xx-foreign-toplevel-geometry-v1.xml");
}
use self::__interfaces::*;
wayland_scanner::generate_client_code!("protocols/xx-foreign-toplevel-geometry-v1.xml");
}
}
use protocol::client::{
xx_foreign_toplevel_geometry_tracker_v1::{self, XxForeignToplevelGeometryTrackerV1},
xx_foreign_toplevel_geometry_tracking_manager_v1::XxForeignToplevelGeometryTrackingManagerV1,
};
struct OutputInfo {
global_name: u32,
logical_x: i32,
logical_y: i32,
hw_width: i32,
hw_height: i32,
logical_width: i32,
logical_height: i32,
}
impl OutputInfo {
/// Convert hardware space coords to global logical.
fn hw_to_logical(&self, x: i32, y: i32, w: i32, h: i32) -> (i32, i32, i32, i32) {
let (sx, sy) = if self.logical_width > 0 && self.logical_height > 0 {
(
self.hw_width as f64 / self.logical_width as f64,
self.hw_height as f64 / self.logical_height as f64,
)
} else {
(1.0, 1.0)
};
let lx = self.logical_x + (x as f64 / sx).round() as i32;
let ly = self.logical_y + (y as f64 / sy).round() as i32;
let lw = (w as f64 / sx).round() as i32;
let lh = (h as f64 / sy).round() as i32;
(lx, ly, lw, lh)
}
}
struct ToplevelInfo {
handle: ExtForeignToplevelHandleV1,
app_id: String,
geometry: Option<(u32, i32, i32, i32, i32)>,
}
struct St {
queue: Option<EventQueue<St>>,
outputs: Vec<OutputInfo>,
out_idx: HashMap<u32, usize>,
toplevels: Vec<ToplevelInfo>,
_trackers: Vec<XxForeignToplevelGeometryTrackerV1>,
}
impl St {
fn roundtrip(&mut self) -> Option<()> {
let mut q = self.queue.take().unwrap();
let ok = q.roundtrip(self).is_ok();
self.queue = Some(q);
ok.then_some(())
}
}
impl Dispatch<wl_registry::WlRegistry, GlobalListContents> for St {
fn event(
_: &mut Self,
_: &wl_registry::WlRegistry,
_: wl_registry::Event,
_: &GlobalListContents,
_: &Connection,
_: &QueueHandle<Self>,
) {
}
}
impl Dispatch<wl_output::WlOutput, u32> for St {
fn event(
state: &mut Self,
_: &wl_output::WlOutput,
event: wl_output::Event,
gname: &u32,
_: &Connection,
_: &QueueHandle<Self>,
) {
if let wl_output::Event::Mode {
flags: WEnum::Value(flags),
width,
height,
..
} = event
{
if flags.contains(wl_output::Mode::Current) {
let idx = match state.out_idx.get(gname) {
Some(&i) => i,
None => {
let i = state.outputs.len();
state.outputs.push(OutputInfo {
global_name: *gname,
logical_x: 0,
logical_y: 0,
hw_width: 0,
hw_height: 0,
logical_width: 0,
logical_height: 0,
});
state.out_idx.insert(*gname, i);
i
}
};
state.outputs[idx].hw_width = width;
state.outputs[idx].hw_height = height;
}
}
}
}
impl Dispatch<ZxdgOutputV1, u32> for St {
fn event(
state: &mut Self,
_: &ZxdgOutputV1,
event: zxdg_output_v1::Event,
gname: &u32,
_: &Connection,
_: &QueueHandle<Self>,
) {
let Some(&idx) = state.out_idx.get(gname) else {
return;
};
match event {
zxdg_output_v1::Event::LogicalPosition { x, y } => {
state.outputs[idx].logical_x = x;
state.outputs[idx].logical_y = y;
}
zxdg_output_v1::Event::LogicalSize { width, height } => {
state.outputs[idx].logical_width = width;
state.outputs[idx].logical_height = height;
}
_ => {}
}
}
}
/// The list sends toplevel events, each new handle needs () user data.
impl Dispatch<ExtForeignToplevelListV1, ()> for St {
fn event(
state: &mut Self,
_: &ExtForeignToplevelListV1,
event: ext_foreign_toplevel_list_v1::Event,
_: &(),
_: &Connection,
_: &QueueHandle<Self>,
) {
if let ext_foreign_toplevel_list_v1::Event::Toplevel { toplevel } = event {
state.toplevels.push(ToplevelInfo {
handle: toplevel,
app_id: String::new(),
geometry: None,
});
}
}
// opcode 0 = toplevel event (server creates a new ExtForeignToplevelHandleV1)
event_created_child!(St, ExtForeignToplevelListV1, [
0 => (ExtForeignToplevelHandleV1, ())
]);
}
impl Dispatch<ExtForeignToplevelHandleV1, ()> for St {
fn event(
state: &mut Self,
handle: &ExtForeignToplevelHandleV1,
event: ext_foreign_toplevel_handle_v1::Event,
_: &(),
_: &Connection,
_: &QueueHandle<Self>,
) {
let id = handle.id();
let Some(tl) = state.toplevels.iter_mut().find(|t| t.handle.id() == id) else {
return;
};
if let ext_foreign_toplevel_handle_v1::Event::AppId { app_id } = event {
tl.app_id = app_id;
}
}
}
/// User data is an index into state.toplevels.
impl Dispatch<XxForeignToplevelGeometryTrackerV1, usize> for St {
fn event(
state: &mut Self,
_: &XxForeignToplevelGeometryTrackerV1,
event: xx_foreign_toplevel_geometry_tracker_v1::Event,
idx: &usize,
_: &Connection,
_: &QueueHandle<Self>,
) {
if let xx_foreign_toplevel_geometry_tracker_v1::Event::Geometry {
output,
x,
y,
width,
height,
} = event
{
let Some(tl) = state.toplevels.get_mut(*idx) else {
return;
};
let area = width as i64 * height as i64;
let prev = tl
.geometry
.map(|(_, _, _, w, h)| w as i64 * h as i64)
.unwrap_or(-1);
if area > prev {
tl.geometry = Some((output, x, y, width as i32, height as i32));
}
}
}
}
delegate_noop!(St: ignore ZxdgOutputManagerV1);
delegate_noop!(St: ignore XxForeignToplevelGeometryTrackingManagerV1);
// Public entry point
//
/// Try to enumerate visible windows via Wayland protocols.
///
/// Returns None if ext_foreign_toplevel_list_v1 or
/// xx_foreign_toplevel_geometry_tracking_v1 are not advertised, so the
/// caller can fall back to Hyprland IPC.
pub fn visible_window_hints() -> Option<Vec<HintBox>> {
let conn = Connection::connect_to_env().ok()?;
let (globals, queue) = registry_queue_init::<St>(&conn).ok()?;
let qh = queue.handle();
// Both protocols are required, return None if either is missing.
let tl_list = globals
.bind::<ExtForeignToplevelListV1, _, _>(&qh, 1..=1, ())
.ok()?;
let geo_mgr = globals
.bind::<XxForeignToplevelGeometryTrackingManagerV1, _, _>(&qh, 1..=1, ())
.ok()?;
let xdg_mgr = globals
.bind::<ZxdgOutputManagerV1, _, _>(&qh, 2..=3, ())
.ok()?;
// Bind wl_outputs with their registry global names as user data.
let out_globals: Vec<(u32, wl_output::WlOutput)> = globals
.contents()
.clone_list()
.iter()
.filter(|g| g.interface == "wl_output")
.map(|g| {
let o = globals.registry().bind::<wl_output::WlOutput, _, _>(
g.name,
g.version.min(4),
&qh,
g.name,
);
(g.name, o)
})
.collect();
// Create one zxdg_output per wl_output (kept alive for the roundtrip).
let _xdg_outputs: Vec<ZxdgOutputV1> = out_globals
.iter()
.map(|(gname, wl_out)| xdg_mgr.get_xdg_output(wl_out, &qh, *gname))
.collect();
let mut st = St {
queue: Some(queue),
outputs: Vec::new(),
out_idx: HashMap::new(),
toplevels: Vec::new(),
_trackers: Vec::new(),
};
st.roundtrip()?;
st.roundtrip()?;
for idx in 0..st.toplevels.len() {
let handle = st.toplevels[idx].handle.clone();
let tracker = geo_mgr.get_geometry_tracker(&handle, &qh, idx);
st._trackers.push(tracker);
}
st.roundtrip()?;
let hints = st
.toplevels
.iter()
.filter_map(|tl| {
let (out_name, x, y, w, h) = tl.geometry?;
let out = st.outputs.iter().find(|o| o.global_name == out_name)?;
let (lx, ly, lw, lh) = out.hw_to_logical(x, y, w, h);
if lw <= 0 || lh <= 0 {
return None;
}
Some(HintBox {
x: lx,
y: ly,
w: lw,
h: lh,
label: tl.app_id.clone(),
})
})
.collect();
drop(_xdg_outputs);
drop(out_globals);
drop(tl_list);
Some(hints)
}