init
This commit is contained in:
commit
1a50f2bf11
23 changed files with 13058 additions and 0 deletions
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
target/
|
||||||
5777
Cargo.lock
generated
Normal file
5777
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
41
Cargo.toml
Normal file
41
Cargo.toml
Normal 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
98
README.md
Normal 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
|
||||||
|
```
|
||||||
120
protocols/xx-foreign-toplevel-geometry-v1.xml
Normal file
120
protocols/xx-foreign-toplevel-geometry-v1.xml
Normal 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
1134
src/capture.rs
Normal file
File diff suppressed because it is too large
Load diff
16
src/clipboard.rs
Normal file
16
src/clipboard.rs
Normal 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
35
src/error.rs
Normal 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
911
src/freeze.rs
Normal 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
1091
src/gui/annotations.rs
Normal file
File diff suppressed because it is too large
Load diff
929
src/gui/app.rs
Normal file
929
src/gui/app.rs
Normal 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
52
src/gui/main.rs
Normal 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
97
src/gui/theme.rs
Normal 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
130
src/gui/widgets.rs
Normal 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
268
src/hyprland.rs
Normal 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
285
src/lib.rs
Normal 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 0–255 (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
490
src/main.rs
Normal 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
37
src/notify.rs
Normal 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
62
src/paths.rs
Normal 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
43
src/region.rs
Normal 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
873
src/select.rs
Normal 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
205
src/shhh.rs
Normal 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
363
src/wayland_windows.rs
Normal 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)
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue