Init
This commit is contained in:
@@ -0,0 +1 @@
|
|||||||
|
/target
|
||||||
Generated
+5396
File diff suppressed because it is too large
Load Diff
+22
@@ -0,0 +1,22 @@
|
|||||||
|
[package]
|
||||||
|
name = "whoshue"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
ashpd = { version = "0.13.13", features = ["tokio", "screencast", "file_chooser"] }
|
||||||
|
eframe = { version = "0.36.2", features = ["wayland"] }
|
||||||
|
enumflags2 = "0.7.12"
|
||||||
|
env_logger = "0.11.11"
|
||||||
|
image = "0.25.10"
|
||||||
|
libspa = "0.10.1"
|
||||||
|
libspa-sys = "0.10.1"
|
||||||
|
log = "0.4.34"
|
||||||
|
pipewire = "0.10.1"
|
||||||
|
serde = { version = "1.0.229", features = ["derive"] }
|
||||||
|
tokio = { version = "1.53.1", features = ["rt-multi-thread", "macros", "sync", "time"] }
|
||||||
|
toml = "1.1.6"
|
||||||
|
url = "2.5"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
zbus = { version = "5.19", default-features = false, features = ["tokio"] }
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
# whoshue
|
||||||
|
|
||||||
|
A pixel-art color analyzer for KDE/Wayland. Capture a game/application window,
|
||||||
|
select part of its preview, and inspect the captured colors. The analyzer is a
|
||||||
|
normal desktop window; it does not flash calibration colors or hide itself.
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
On CachyOS/Arch with KDE, the capture backend uses `xdg-desktop-portal`,
|
||||||
|
`xdg-desktop-portal-kde`, and PipeWire. Building needs Rust, a C toolchain,
|
||||||
|
`pkgconf`, Clang/libclang, and PipeWire development libraries (`libpipewire`).
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo run --release
|
||||||
|
# Or start with a local image, without requesting screen sharing:
|
||||||
|
cargo run --release -- /path/to/screenshot.png
|
||||||
|
```
|
||||||
|
|
||||||
|
1. Click **Choose source…**. KDE handles selecting a window, screen, or region.
|
||||||
|
Click it again to choose a different source.
|
||||||
|
2. Drag a rectangle in the source preview. Capture display pauses during the
|
||||||
|
drag, then returns to its previous live/frozen state. The crop is stored in
|
||||||
|
source pixels and resets if the source dimensions change.
|
||||||
|
3. **Freeze** holds the displayed frame and analysis; **Resume** takes the newest
|
||||||
|
available frame. **Stop sharing** closes capture and keeps the last image.
|
||||||
|
4. Inspect the nearest-neighbor zoomed crop. Hover for source coordinates,
|
||||||
|
RGB/hex and HSV; click to copy hex. Click palette swatches to copy them too.
|
||||||
|
5. **Save crop PNG** writes a uniquely named PNG in the system temporary directory
|
||||||
|
and displays its path. Drop an image or click **Open image…** to browse for a file in KDE's file picker.
|
||||||
|
|
||||||
|
The HSV histogram has 36 ten-degree bins. Switch between pixel counts and equal
|
||||||
|
weight per distinct RGB color. Colors with saturation below 5% are counted
|
||||||
|
separately as neutrals. The palette is exact (no quantization), ordered by count,
|
||||||
|
then RGB for deterministic ties. Fully transparent pixels are excluded; partially
|
||||||
|
transparent pixels retain their stored RGB values and count once.
|
||||||
|
|
||||||
|
Choosing a source always opens KDE's picker. The last portal token is saved at
|
||||||
|
`$XDG_CONFIG_HOME/whoshue/config.toml` (normally `~/.config/whoshue/config.toml`)
|
||||||
|
for the command-line capture diagnostic. No capture starts until **Choose source…**
|
||||||
|
is clicked. Cancelling the file picker leaves the current image/capture unchanged.
|
||||||
|
|
||||||
|
Portal requests share one application-lifetime async runtime. PipeWire runs on a
|
||||||
|
separate blocking worker so restarting capture or opening another portal dialog
|
||||||
|
cannot strand the cached D-Bus connection on a stopped executor.
|
||||||
|
|
||||||
|
## Limits and next work
|
||||||
|
|
||||||
|
- Window capture is preferred: screen/region capture includes overlapping windows,
|
||||||
|
including whoshue if placed inside the captured area.
|
||||||
|
- Selection follows coordinates within the source, not a moving sprite.
|
||||||
|
- Analysis uses source pixels before preview scaling, but these are rendered
|
||||||
|
colors, not necessarily original game-asset colors. HDR and color-profile
|
||||||
|
conversion have not been validated; the current path assumes ordinary SDR RGB.
|
||||||
|
- Live analysis refreshes at most five times per second. Large selections with
|
||||||
|
many unique colors are more expensive; release builds are recommended.
|
||||||
|
- Game-specific behavior on focus loss/minimization needs desktop testing.
|
||||||
|
- Perceptual OKLCH views, color/hue highlighting, and comparing saved selections
|
||||||
|
are future features; this first version uses HSV.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo test --offline --locked
|
||||||
|
cargo clippy --offline --locked --all-targets -- -D warnings
|
||||||
|
cargo fmt --check
|
||||||
|
```
|
||||||
|
|
||||||
|
Tests cover hue boundaries, neutral/transparent pixel handling, weighting,
|
||||||
|
cropping, selection coordinates, pixel-format conversion, frozen-frame behavior,
|
||||||
|
resize handling, headless UI rendering, file-picker cancellation/reopening, and
|
||||||
|
repeated portal requests during/after capture. The portal regression test requires
|
||||||
|
`dbus-run-session` and permission to create a local socket; it uses its own private
|
||||||
|
bus and does not open desktop dialogs. Actual portal capture requires a running
|
||||||
|
Wayland desktop and permission through KDE's picker.
|
||||||
|
|
||||||
|
For a capture-only diagnostic (saves the first full source frame):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo run -- --selftest /tmp/whoshue-selftest.png
|
||||||
|
```
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
//! Analysis always uses source pixels, never the scaled preview.
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use crate::capture::Frame;
|
||||||
|
|
||||||
|
pub const HUE_BINS: usize = 36;
|
||||||
|
pub const NEUTRAL_SATURATION: f32 = 0.05;
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub struct Region {
|
||||||
|
pub x: u32,
|
||||||
|
pub y: u32,
|
||||||
|
pub width: u32,
|
||||||
|
pub height: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Region {
|
||||||
|
pub fn full(frame: &Frame) -> Self {
|
||||||
|
Self {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: frame.width,
|
||||||
|
height: frame.height,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn crop(self, frame: &Frame) -> Vec<u8> {
|
||||||
|
let mut pixels = Vec::new();
|
||||||
|
for y in self.y..self.y.saturating_add(self.height).min(frame.height) {
|
||||||
|
let start =
|
||||||
|
((y as usize * frame.width as usize) + self.x.min(frame.width) as usize) * 4;
|
||||||
|
let end = ((y as usize * frame.width as usize)
|
||||||
|
+ self.x.saturating_add(self.width).min(frame.width) as usize)
|
||||||
|
* 4;
|
||||||
|
pixels.extend_from_slice(&frame.rgba[start..end]);
|
||||||
|
}
|
||||||
|
pixels
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct Swatch {
|
||||||
|
pub rgb: [u8; 3],
|
||||||
|
pub count: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Analysis {
|
||||||
|
pub palette: Vec<Swatch>,
|
||||||
|
pub pixels: u64,
|
||||||
|
pub neutral_pixels: u64,
|
||||||
|
pub neutral_colors: u64,
|
||||||
|
pub hue_pixels: [u64; HUE_BINS],
|
||||||
|
pub hue_colors: [u64; HUE_BINS],
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Analysis {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
palette: Vec::new(),
|
||||||
|
pixels: 0,
|
||||||
|
neutral_pixels: 0,
|
||||||
|
neutral_colors: 0,
|
||||||
|
hue_pixels: [0; HUE_BINS],
|
||||||
|
hue_colors: [0; HUE_BINS],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// HSV hue in degrees, saturation and value in 0..=1.
|
||||||
|
pub fn hsv(rgb: [u8; 3]) -> [f32; 3] {
|
||||||
|
let [r, g, b] = rgb.map(|v| v as f32 / 255.0);
|
||||||
|
let max = r.max(g).max(b);
|
||||||
|
let min = r.min(g).min(b);
|
||||||
|
let delta = max - min;
|
||||||
|
if delta == 0.0 {
|
||||||
|
return [0.0, 0.0, max];
|
||||||
|
}
|
||||||
|
let hue = if max == r {
|
||||||
|
((g - b) / delta).rem_euclid(6.0)
|
||||||
|
} else if max == g {
|
||||||
|
(b - r) / delta + 2.0
|
||||||
|
} else {
|
||||||
|
(r - g) / delta + 4.0
|
||||||
|
};
|
||||||
|
[hue * 60.0, delta / max, max]
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn analyze(rgba: &[u8]) -> Analysis {
|
||||||
|
let mut counts = HashMap::<[u8; 3], u64>::new();
|
||||||
|
for pixel in rgba.as_chunks::<4>().0 {
|
||||||
|
// Ignore fully transparent pixels; count other captured RGB values exactly.
|
||||||
|
if pixel[3] != 0 {
|
||||||
|
*counts.entry([pixel[0], pixel[1], pixel[2]]).or_default() += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut result = Analysis::default();
|
||||||
|
for (rgb, count) in counts {
|
||||||
|
result.pixels += count;
|
||||||
|
let [h, s, _] = hsv(rgb);
|
||||||
|
if s < NEUTRAL_SATURATION {
|
||||||
|
result.neutral_pixels += count;
|
||||||
|
result.neutral_colors += 1;
|
||||||
|
} else {
|
||||||
|
let bin = (h / 10.0).floor() as usize % HUE_BINS;
|
||||||
|
result.hue_pixels[bin] += count;
|
||||||
|
result.hue_colors[bin] += 1;
|
||||||
|
}
|
||||||
|
result.palette.push(Swatch { rgb, count });
|
||||||
|
}
|
||||||
|
result
|
||||||
|
.palette
|
||||||
|
.sort_unstable_by(|a, b| b.count.cmp(&a.count).then(a.rgb.cmp(&b.rgb)));
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn primary_hues_and_wraparound() {
|
||||||
|
assert_eq!(hsv([255, 0, 0]), [0.0, 1.0, 1.0]);
|
||||||
|
assert_eq!(hsv([0, 255, 0])[0], 120.0);
|
||||||
|
assert_eq!(hsv([0, 0, 255])[0], 240.0);
|
||||||
|
assert!(hsv([255, 0, 1])[0] > 359.0);
|
||||||
|
assert_eq!(hsv([0, 0, 0]), [0.0, 0.0, 0.0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn frequency_distinct_weights_neutrals_and_transparency() {
|
||||||
|
let data = [
|
||||||
|
255, 0, 0, 255, 255, 0, 0, 255, 128, 0, 0, 255, 0, 255, 0, 255, 128, 128, 128, 255,
|
||||||
|
255, 0, 255, 0,
|
||||||
|
];
|
||||||
|
let a = analyze(&data);
|
||||||
|
assert_eq!(a.pixels, 5);
|
||||||
|
assert_eq!(a.palette.len(), 4);
|
||||||
|
assert_eq!(a.palette[0].rgb, [255, 0, 0]);
|
||||||
|
assert_eq!(a.hue_pixels[0], 3);
|
||||||
|
assert_eq!(a.hue_colors[0], 2);
|
||||||
|
assert_eq!(a.neutral_pixels, 1);
|
||||||
|
assert_eq!(
|
||||||
|
a.hue_pixels.iter().sum::<u64>() + a.neutral_pixels,
|
||||||
|
a.pixels
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn crop_uses_source_coordinates_and_clamps() {
|
||||||
|
let frame = Frame {
|
||||||
|
seq: 0,
|
||||||
|
width: 3,
|
||||||
|
height: 2,
|
||||||
|
rgba: (0..24).collect(),
|
||||||
|
};
|
||||||
|
let r = Region {
|
||||||
|
x: 1,
|
||||||
|
y: 0,
|
||||||
|
width: 1,
|
||||||
|
height: 2,
|
||||||
|
};
|
||||||
|
assert_eq!(r.crop(&frame), [4, 5, 6, 7, 16, 17, 18, 19]);
|
||||||
|
let edge = Region {
|
||||||
|
x: 2,
|
||||||
|
y: 1,
|
||||||
|
width: 10,
|
||||||
|
height: 10,
|
||||||
|
};
|
||||||
|
assert_eq!(edge.crop(&frame), [20, 21, 22, 23]);
|
||||||
|
}
|
||||||
|
}
|
||||||
+596
@@ -0,0 +1,596 @@
|
|||||||
|
//! Window/region preview and pixel-color analysis.
|
||||||
|
use crate::{
|
||||||
|
analysis::{self, Analysis, Region},
|
||||||
|
capture::{self, Frame, SharedCapture, Status},
|
||||||
|
config::Config,
|
||||||
|
ui,
|
||||||
|
};
|
||||||
|
use eframe::egui::{self, Color32};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
pub struct WhosHueApp {
|
||||||
|
capture: Option<SharedCapture>,
|
||||||
|
config: Config,
|
||||||
|
file_picker: Option<tokio::sync::oneshot::Receiver<crate::file_dialog::Selection>>,
|
||||||
|
frame: Option<Frame>,
|
||||||
|
texture: Option<egui::TextureHandle>,
|
||||||
|
crop_texture: Option<egui::TextureHandle>,
|
||||||
|
region: Option<Region>,
|
||||||
|
analysis: Analysis,
|
||||||
|
frozen: bool,
|
||||||
|
distinct: bool,
|
||||||
|
zoom: f32,
|
||||||
|
drag_origin: Option<egui::Pos2>,
|
||||||
|
was_frozen: bool,
|
||||||
|
last_update: Instant,
|
||||||
|
message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WhosHueApp {
|
||||||
|
pub fn new(cc: &eframe::CreationContext<'_>) -> Self {
|
||||||
|
let mut app = Self::empty(Config::load());
|
||||||
|
if let Some(path) = std::env::args().nth(1) {
|
||||||
|
app.load_image(&cc.egui_ctx, std::path::Path::new(&path));
|
||||||
|
}
|
||||||
|
app
|
||||||
|
}
|
||||||
|
|
||||||
|
fn empty(config: Config) -> Self {
|
||||||
|
Self {
|
||||||
|
capture: None,
|
||||||
|
config,
|
||||||
|
file_picker: None,
|
||||||
|
frame: None,
|
||||||
|
texture: None,
|
||||||
|
crop_texture: None,
|
||||||
|
region: None,
|
||||||
|
analysis: Analysis::default(),
|
||||||
|
frozen: false,
|
||||||
|
distinct: false,
|
||||||
|
zoom: 2.0,
|
||||||
|
drag_origin: None,
|
||||||
|
was_frozen: false,
|
||||||
|
last_update: Instant::now(),
|
||||||
|
message: String::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stop(&mut self) {
|
||||||
|
if let Some(capture) = self.capture.take() {
|
||||||
|
capture.lock().unwrap().stop = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn start(&mut self) {
|
||||||
|
self.stop();
|
||||||
|
// A new selection always opens KDE's picker; never silently restore it.
|
||||||
|
self.capture = Some(capture::spawn(None));
|
||||||
|
self.frame = None;
|
||||||
|
self.texture = None;
|
||||||
|
self.crop_texture = None;
|
||||||
|
self.region = None;
|
||||||
|
self.analysis = Analysis::default();
|
||||||
|
self.frozen = false;
|
||||||
|
self.drag_origin = None;
|
||||||
|
self.message.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn texture(
|
||||||
|
ctx: &egui::Context,
|
||||||
|
slot: &mut Option<egui::TextureHandle>,
|
||||||
|
name: &str,
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
rgba: &[u8],
|
||||||
|
) {
|
||||||
|
let image =
|
||||||
|
egui::ColorImage::from_rgba_unmultiplied([width as usize, height as usize], rgba);
|
||||||
|
if let Some(texture) = slot {
|
||||||
|
texture.set(image, egui::TextureOptions::NEAREST);
|
||||||
|
} else {
|
||||||
|
*slot = Some(ctx.load_texture(name, image, egui::TextureOptions::NEAREST));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_frame(&mut self, ctx: &egui::Context, frame: Frame) {
|
||||||
|
if self
|
||||||
|
.frame
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|old| (old.width, old.height) != (frame.width, frame.height))
|
||||||
|
{
|
||||||
|
self.region = None;
|
||||||
|
self.message = "Source dimensions changed; selection reset.".into();
|
||||||
|
}
|
||||||
|
Self::texture(
|
||||||
|
ctx,
|
||||||
|
&mut self.texture,
|
||||||
|
"source",
|
||||||
|
frame.width,
|
||||||
|
frame.height,
|
||||||
|
&frame.rgba,
|
||||||
|
);
|
||||||
|
self.frame = Some(frame);
|
||||||
|
self.analyze(ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn analyze(&mut self, ctx: &egui::Context) {
|
||||||
|
if let Some(frame) = &self.frame {
|
||||||
|
let region = self.region.unwrap_or_else(|| Region::full(frame));
|
||||||
|
let pixels = region.crop(frame);
|
||||||
|
self.analysis = analysis::analyze(&pixels);
|
||||||
|
Self::texture(
|
||||||
|
ctx,
|
||||||
|
&mut self.crop_texture,
|
||||||
|
"crop",
|
||||||
|
region.width,
|
||||||
|
region.height,
|
||||||
|
&pixels,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_image(&mut self, ctx: &egui::Context, path: &std::path::Path) {
|
||||||
|
match image::open(path) {
|
||||||
|
Ok(image) => {
|
||||||
|
let rgba = image.to_rgba8();
|
||||||
|
if rgba.width() == 0 || rgba.height() == 0 {
|
||||||
|
self.message = "Image is empty.".into();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.stop();
|
||||||
|
self.region = None;
|
||||||
|
self.set_frame(
|
||||||
|
ctx,
|
||||||
|
Frame {
|
||||||
|
seq: 0,
|
||||||
|
width: rgba.width(),
|
||||||
|
height: rgba.height(),
|
||||||
|
rgba: rgba.into_raw(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
self.frozen = true;
|
||||||
|
self.message = format!("Loaded {}", path.display());
|
||||||
|
}
|
||||||
|
Err(error) => self.message = format!("Could not open image: {error}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open_image(&mut self, ctx: &egui::Context) {
|
||||||
|
if self.file_picker.is_some() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let (sender, receiver) = tokio::sync::oneshot::channel();
|
||||||
|
self.file_picker = Some(receiver);
|
||||||
|
let ctx = ctx.clone();
|
||||||
|
crate::runtime::get().spawn(async move {
|
||||||
|
let _ = sender.send(crate::file_dialog::open_image().await);
|
||||||
|
ctx.request_repaint();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn export(&mut self) {
|
||||||
|
if let Some(frame) = &self.frame {
|
||||||
|
let r = self.region.unwrap_or_else(|| Region::full(frame));
|
||||||
|
// Unique output preserves earlier captures.
|
||||||
|
let stamp = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_nanos();
|
||||||
|
let path = std::env::temp_dir().join(format!("whoshue-{stamp}.png"));
|
||||||
|
self.message = match image::save_buffer(
|
||||||
|
&path,
|
||||||
|
&r.crop(frame),
|
||||||
|
r.width,
|
||||||
|
r.height,
|
||||||
|
image::ColorType::Rgba8,
|
||||||
|
) {
|
||||||
|
Ok(()) => format!("Saved {}", path.display()),
|
||||||
|
Err(error) => format!("Save failed: {error}"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn poll(&mut self, ctx: &egui::Context) {
|
||||||
|
if let Some(receiver) = &mut self.file_picker {
|
||||||
|
match receiver.try_recv() {
|
||||||
|
Ok(result) => {
|
||||||
|
self.file_picker = None;
|
||||||
|
match result {
|
||||||
|
Ok(Some(path)) => self.load_image(ctx, &path),
|
||||||
|
Ok(None) => {}
|
||||||
|
Err(error) => self.message = error,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(tokio::sync::oneshot::error::TryRecvError::Closed) => {
|
||||||
|
self.file_picker = None;
|
||||||
|
self.message = "File picker closed unexpectedly. Please try again.".into();
|
||||||
|
}
|
||||||
|
Err(tokio::sync::oneshot::error::TryRecvError::Empty) => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut next = None;
|
||||||
|
if let Some(capture) = &self.capture {
|
||||||
|
let mut guard = capture.lock().unwrap();
|
||||||
|
if let Some(token) = guard.new_restore_token.take() {
|
||||||
|
self.config.restore_token = Some(token);
|
||||||
|
self.config.save();
|
||||||
|
}
|
||||||
|
if !self.frozen
|
||||||
|
&& self.drag_origin.is_none()
|
||||||
|
&& self.last_update.elapsed() >= Duration::from_millis(200)
|
||||||
|
{
|
||||||
|
if let Some(frame) = &guard.frame
|
||||||
|
&& self.frame.as_ref().is_none_or(|old| old.seq != frame.seq)
|
||||||
|
{
|
||||||
|
next = Some(frame.clone());
|
||||||
|
}
|
||||||
|
self.last_update = Instant::now();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(frame) = next {
|
||||||
|
self.set_frame(ctx, frame);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn preview(&mut self, ui: &mut egui::Ui) {
|
||||||
|
let Some(frame) = &self.frame else {
|
||||||
|
ui.heading("Choose a window to study its colors");
|
||||||
|
ui.label("Select a game or application, then drag a region in its preview.");
|
||||||
|
ui.label("You can also drop an image here or use Open image….");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let (width, height) = (frame.width, frame.height);
|
||||||
|
ui.label("Source preview · drag to select a region · selection follows source pixels");
|
||||||
|
let available = egui::vec2(
|
||||||
|
ui.available_width(),
|
||||||
|
(ui.available_height() * 0.47).max(100.0),
|
||||||
|
);
|
||||||
|
let scale = (available.x / width as f32)
|
||||||
|
.min(available.y / height as f32)
|
||||||
|
.max(0.001);
|
||||||
|
let size = egui::vec2(width as f32 * scale, height as f32 * scale);
|
||||||
|
let response = ui.add(
|
||||||
|
egui::Image::new((self.texture.as_ref().unwrap().id(), size))
|
||||||
|
.sense(egui::Sense::drag()),
|
||||||
|
);
|
||||||
|
if response.drag_started() {
|
||||||
|
self.drag_origin = ui.input(|i| i.pointer.press_origin());
|
||||||
|
self.was_frozen = self.frozen;
|
||||||
|
self.frozen = true;
|
||||||
|
}
|
||||||
|
if let (Some(origin), Some(pointer)) = (self.drag_origin, response.interact_pointer_pos()) {
|
||||||
|
let region = ui::selection(origin, pointer, response.rect, width, height);
|
||||||
|
if self.region != Some(region) {
|
||||||
|
self.region = Some(region);
|
||||||
|
self.analyze(ui.ctx());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if self.drag_origin.is_some()
|
||||||
|
&& (response.drag_stopped() || !ui.input(|i| i.pointer.primary_down()))
|
||||||
|
{
|
||||||
|
self.drag_origin = None;
|
||||||
|
self.frozen = self.was_frozen;
|
||||||
|
}
|
||||||
|
if let Some(r) = self.region {
|
||||||
|
let rect = egui::Rect::from_min_size(
|
||||||
|
response.rect.min + egui::vec2(r.x as f32, r.y as f32) * scale,
|
||||||
|
egui::vec2(r.width as f32, r.height as f32) * scale,
|
||||||
|
);
|
||||||
|
ui.painter().rect_stroke(
|
||||||
|
rect,
|
||||||
|
0.0,
|
||||||
|
egui::Stroke::new(2.0, Color32::WHITE),
|
||||||
|
egui::StrokeKind::Inside,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let region = self.region.unwrap_or(Region {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
});
|
||||||
|
ui.separator();
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.label(format!(
|
||||||
|
"Selection: {} × {} at {}, {}",
|
||||||
|
region.width, region.height, region.x, region.y
|
||||||
|
));
|
||||||
|
if ui.button("Whole image").clicked() {
|
||||||
|
self.region = None;
|
||||||
|
self.analyze(ui.ctx());
|
||||||
|
}
|
||||||
|
ui.add(
|
||||||
|
egui::Slider::new(&mut self.zoom, 1.0..=16.0)
|
||||||
|
.text("Zoom")
|
||||||
|
.integer(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
egui::ScrollArea::both().id_salt("crop_scroll").show(ui, |ui| {
|
||||||
|
if let Some(texture) = &self.crop_texture {
|
||||||
|
let response = ui.add(egui::Image::new((texture.id(), texture.size_vec2() * self.zoom)).sense(egui::Sense::click()));
|
||||||
|
if let Some(pos) = response.hover_pos() {
|
||||||
|
let x = ((pos.x-response.rect.left()) / self.zoom).floor() as u32;
|
||||||
|
let y = ((pos.y-response.rect.top()) / self.zoom).floor() as u32;
|
||||||
|
if x < region.width && y < region.height {
|
||||||
|
let frame = self.frame.as_ref().unwrap();
|
||||||
|
let index = (((region.y+y)*frame.width + region.x+x)*4) as usize;
|
||||||
|
let rgb = [frame.rgba[index],frame.rgba[index+1],frame.rgba[index+2]];
|
||||||
|
let [h,s,v] = analysis::hsv(rgb);
|
||||||
|
let hex = format!("#{:02X}{:02X}{:02X}",rgb[0],rgb[1],rgb[2]);
|
||||||
|
if response.clicked() { ui.ctx().copy_text(hex.clone()); }
|
||||||
|
response.on_hover_text(format!("({}, {}) {hex}\nRGB {}, {}, {}\nHSV {:.1}°, {:.1}%, {:.1}%\nClick to copy hex",region.x+x,region.y+y,rgb[0],rgb[1],rgb[2],h,s*100.0,v*100.0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sidebar(&mut self, ui: &mut egui::Ui) {
|
||||||
|
ui.heading("Hue distribution");
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.selectable_value(&mut self.distinct, false, "By pixel count");
|
||||||
|
ui.selectable_value(&mut self.distinct, true, "By distinct color");
|
||||||
|
});
|
||||||
|
ui::histogram(ui, &self.analysis, self.distinct);
|
||||||
|
ui.label(format!(
|
||||||
|
"Neutrals: {} pixels / {} colors",
|
||||||
|
self.analysis.neutral_pixels, self.analysis.neutral_colors
|
||||||
|
));
|
||||||
|
ui.small("HSV · 10° bins · saturation below 5% counted as neutral");
|
||||||
|
ui.separator();
|
||||||
|
ui.heading("Exact palette");
|
||||||
|
ui.label(format!(
|
||||||
|
"{} colors · {} nontransparent pixels",
|
||||||
|
self.analysis.palette.len(),
|
||||||
|
self.analysis.pixels
|
||||||
|
));
|
||||||
|
ui.small("Most frequent first · click a swatch to copy hex");
|
||||||
|
// Virtual rows keep large true-color captures responsive.
|
||||||
|
egui::ScrollArea::vertical().id_salt("palette").show_rows(
|
||||||
|
ui,
|
||||||
|
24.0,
|
||||||
|
self.analysis.palette.len(),
|
||||||
|
|ui, rows| {
|
||||||
|
for index in rows {
|
||||||
|
let swatch = &self.analysis.palette[index];
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
let [r, g, b] = swatch.rgb;
|
||||||
|
let hex = format!("#{r:02X}{g:02X}{b:02X}");
|
||||||
|
let (rect, response) =
|
||||||
|
ui.allocate_exact_size(egui::vec2(28.0, 20.0), egui::Sense::click());
|
||||||
|
ui.painter()
|
||||||
|
.rect_filled(rect, 3.0, Color32::from_rgb(r, g, b));
|
||||||
|
if response.clicked() {
|
||||||
|
ui.ctx().copy_text(hex.clone());
|
||||||
|
}
|
||||||
|
ui.monospace(&hex);
|
||||||
|
ui.label(format!(
|
||||||
|
"{:.1}%",
|
||||||
|
100.0 * swatch.count as f64 / self.analysis.pixels.max(1) as f64
|
||||||
|
));
|
||||||
|
let [h, s, v] = analysis::hsv(swatch.rgb);
|
||||||
|
response.on_hover_text(format!(
|
||||||
|
"RGB {r}, {g}, {b}\nHSV {h:.1}°, {:.1}%, {:.1}%\n{} pixels",
|
||||||
|
s * 100.0,
|
||||||
|
v * 100.0,
|
||||||
|
swatch.count
|
||||||
|
));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for WhosHueApp {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl eframe::App for WhosHueApp {
|
||||||
|
fn logic(&mut self, ctx: &egui::Context, _: &mut eframe::Frame) {
|
||||||
|
self.poll(ctx);
|
||||||
|
if let Some(path) = ctx.input(|i| {
|
||||||
|
i.raw
|
||||||
|
.dropped_files
|
||||||
|
.iter()
|
||||||
|
.map(|file| file.path().to_path_buf())
|
||||||
|
.next()
|
||||||
|
}) {
|
||||||
|
self.load_image(ctx, &path);
|
||||||
|
}
|
||||||
|
ctx.request_repaint_after(Duration::from_millis(100));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ui(&mut self, ui: &mut egui::Ui, _: &mut eframe::Frame) {
|
||||||
|
let ctx = ui.ctx().clone();
|
||||||
|
ui.horizontal_wrapped(|ui| {
|
||||||
|
ui.heading("whoshue");
|
||||||
|
if ui.button("Choose source…").clicked() {
|
||||||
|
self.start();
|
||||||
|
}
|
||||||
|
if ui
|
||||||
|
.add_enabled(
|
||||||
|
self.file_picker.is_none(),
|
||||||
|
egui::Button::new(if self.file_picker.is_some() {
|
||||||
|
"Choosing image…"
|
||||||
|
} else {
|
||||||
|
"Open image…"
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
self.open_image(&ctx);
|
||||||
|
}
|
||||||
|
let has_frame = self.frame.is_some();
|
||||||
|
if ui
|
||||||
|
.add_enabled(
|
||||||
|
has_frame && self.capture.is_some(),
|
||||||
|
egui::Button::new(if self.frozen { "Resume" } else { "Freeze" }),
|
||||||
|
)
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
self.frozen = !self.frozen;
|
||||||
|
}
|
||||||
|
if ui
|
||||||
|
.add_enabled(has_frame, egui::Button::new("Save crop PNG"))
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
self.export();
|
||||||
|
}
|
||||||
|
if self.capture.is_some() && ui.button("Stop sharing").clicked() {
|
||||||
|
self.stop();
|
||||||
|
self.frozen = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if let Some(capture) = &self.capture {
|
||||||
|
match &capture.lock().unwrap().status {
|
||||||
|
Status::Connecting => {
|
||||||
|
ui.label("Connecting to screen sharing…");
|
||||||
|
}
|
||||||
|
Status::WaitingForPermission => {
|
||||||
|
ui.label("Choose a source in KDE’s sharing dialog.");
|
||||||
|
}
|
||||||
|
Status::Streaming { width, height } => {
|
||||||
|
ui.label(format!(
|
||||||
|
"{} · {width} × {height}",
|
||||||
|
if self.frozen { "Frozen" } else { "Live" }
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Status::Failed(error) => {
|
||||||
|
ui.colored_label(Color32::LIGHT_RED, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if self.frame.is_some() {
|
||||||
|
ui.label("Frozen image");
|
||||||
|
}
|
||||||
|
if !self.message.is_empty() {
|
||||||
|
ui.label(&self.message);
|
||||||
|
}
|
||||||
|
ui.separator();
|
||||||
|
egui::Panel::right("analysis_panel")
|
||||||
|
.default_size(330.0)
|
||||||
|
.min_size(300.0)
|
||||||
|
.show(ui, |ui| self.sidebar(ui));
|
||||||
|
egui::CentralPanel::default().show(ui, |ui| self.preview(ui));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
fn sample(seq: u64, width: u32) -> Frame {
|
||||||
|
Frame {
|
||||||
|
seq,
|
||||||
|
width,
|
||||||
|
height: 2,
|
||||||
|
rgba: [255, 0, 0, 255].repeat((width * 2) as usize),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn frozen_frame_survives_updates_and_resize_resets_crop() {
|
||||||
|
let ctx = egui::Context::default();
|
||||||
|
let mut app = WhosHueApp::empty(Config::default());
|
||||||
|
app.set_frame(&ctx, sample(1, 4));
|
||||||
|
app.region = Some(Region {
|
||||||
|
x: 1,
|
||||||
|
y: 0,
|
||||||
|
width: 2,
|
||||||
|
height: 1,
|
||||||
|
});
|
||||||
|
app.analyze(&ctx);
|
||||||
|
assert_eq!(app.analysis.pixels, 2);
|
||||||
|
app.capture = Some(Arc::new(Mutex::new(capture::Shared {
|
||||||
|
stop: false,
|
||||||
|
frame: Some(sample(2, 8)),
|
||||||
|
status: Status::Streaming {
|
||||||
|
width: 8,
|
||||||
|
height: 2,
|
||||||
|
},
|
||||||
|
new_restore_token: None,
|
||||||
|
})));
|
||||||
|
app.frozen = true;
|
||||||
|
app.last_update = Instant::now() - Duration::from_secs(1);
|
||||||
|
app.poll(&ctx);
|
||||||
|
assert_eq!(app.frame.as_ref().unwrap().seq, 1);
|
||||||
|
assert!(app.region.is_some());
|
||||||
|
app.frozen = false;
|
||||||
|
app.poll(&ctx);
|
||||||
|
assert_eq!(app.frame.as_ref().unwrap().seq, 2);
|
||||||
|
assert_eq!(app.region, None);
|
||||||
|
assert_eq!(app.analysis.pixels, 16);
|
||||||
|
let shared = app.capture.clone().unwrap();
|
||||||
|
app.stop();
|
||||||
|
assert!(shared.lock().unwrap().stop);
|
||||||
|
assert_eq!(app.frame.as_ref().unwrap().seq, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn file_picker_cancel_and_reopen_preserve_capture_until_image_is_loaded() {
|
||||||
|
let ctx = egui::Context::default();
|
||||||
|
let mut app = WhosHueApp::empty(Config::default());
|
||||||
|
app.set_frame(&ctx, sample(1, 4));
|
||||||
|
app.frozen = true;
|
||||||
|
let shared = Arc::new(Mutex::new(capture::Shared {
|
||||||
|
stop: false,
|
||||||
|
frame: None,
|
||||||
|
status: Status::Connecting,
|
||||||
|
new_restore_token: None,
|
||||||
|
}));
|
||||||
|
app.capture = Some(shared.clone());
|
||||||
|
|
||||||
|
let (sender, receiver) = tokio::sync::oneshot::channel();
|
||||||
|
app.file_picker = Some(receiver);
|
||||||
|
sender.send(Ok(None)).unwrap();
|
||||||
|
app.poll(&ctx);
|
||||||
|
assert!(app.file_picker.is_none());
|
||||||
|
assert!(!shared.lock().unwrap().stop);
|
||||||
|
assert_eq!(app.frame.as_ref().unwrap().width, 4);
|
||||||
|
|
||||||
|
let stamp = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_nanos();
|
||||||
|
let path = std::env::temp_dir().join(format!("whoshue picker test ü {stamp}.png"));
|
||||||
|
image::save_buffer(&path, &[0, 255, 0, 255], 1, 1, image::ColorType::Rgba8).unwrap();
|
||||||
|
let (sender, receiver) = tokio::sync::oneshot::channel();
|
||||||
|
app.file_picker = Some(receiver);
|
||||||
|
sender.send(Ok(Some(path.clone()))).unwrap();
|
||||||
|
app.poll(&ctx);
|
||||||
|
std::fs::remove_file(path).unwrap();
|
||||||
|
assert!(app.file_picker.is_none());
|
||||||
|
assert!(shared.lock().unwrap().stop);
|
||||||
|
assert!(app.capture.is_none());
|
||||||
|
assert!(app.frozen);
|
||||||
|
assert_eq!(app.frame.as_ref().unwrap().rgba, [0, 255, 0, 255]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn preview_and_palette_render_with_source_image() {
|
||||||
|
let ctx = egui::Context::default();
|
||||||
|
let mut app = WhosHueApp::empty(Config::default());
|
||||||
|
app.set_frame(&ctx, sample(1, 32));
|
||||||
|
let mut output = ctx.run_ui(
|
||||||
|
egui::RawInput {
|
||||||
|
screen_rect: Some(egui::Rect::from_min_size(
|
||||||
|
egui::Pos2::ZERO,
|
||||||
|
egui::vec2(1200.0, 800.0),
|
||||||
|
)),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
|ui| {
|
||||||
|
egui::Panel::right("test_analysis")
|
||||||
|
.default_size(330.0)
|
||||||
|
.show(ui, |ui| app.sidebar(ui));
|
||||||
|
egui::CentralPanel::default().show(ui, |ui| app.preview(ui));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert!(!output.shapes.is_empty());
|
||||||
|
assert!(!output.textures_delta.set.is_empty());
|
||||||
|
output.textures_delta.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
//! Screen capture via XDG Desktop Portal (ScreenCast) + PipeWire.
|
||||||
|
//!
|
||||||
|
//! Portal requests use the persistent app runtime. PipeWire runs separately so
|
||||||
|
//! the cached D-Bus connection stays responsive across captures and dialogs.
|
||||||
|
|
||||||
|
pub mod pipewire;
|
||||||
|
pub mod portal;
|
||||||
|
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
/// A single captured source frame in stream pixel coordinates.
|
||||||
|
pub struct Frame {
|
||||||
|
/// Monotonically increasing sequence number; UI compares to detect new frames.
|
||||||
|
pub seq: u64,
|
||||||
|
pub width: u32,
|
||||||
|
pub height: u32,
|
||||||
|
/// Row-major RGBA8, `width * height * 4` bytes, no padding.
|
||||||
|
pub rgba: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub enum Status {
|
||||||
|
Connecting,
|
||||||
|
WaitingForPermission,
|
||||||
|
Streaming { width: u32, height: u32 },
|
||||||
|
Failed(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Shared {
|
||||||
|
pub stop: bool,
|
||||||
|
pub frame: Option<Frame>,
|
||||||
|
pub status: Status,
|
||||||
|
/// Restore token returned by the portal after a successful (persisted) start.
|
||||||
|
/// Read once by the UI thread at startup and persisted to config.
|
||||||
|
pub new_restore_token: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type SharedCapture = Arc<Mutex<Shared>>;
|
||||||
|
|
||||||
|
pub fn spawn(restore_token: Option<String>) -> SharedCapture {
|
||||||
|
let shared: SharedCapture = Arc::new(Mutex::new(Shared {
|
||||||
|
stop: false,
|
||||||
|
frame: None,
|
||||||
|
status: Status::Connecting,
|
||||||
|
new_restore_token: None,
|
||||||
|
}));
|
||||||
|
|
||||||
|
let shared_thread = Arc::clone(&shared);
|
||||||
|
crate::runtime::get().spawn(async move {
|
||||||
|
match portal::connect(restore_token, &shared_thread).await {
|
||||||
|
Ok(session) => {
|
||||||
|
let consumer = shared_thread.clone();
|
||||||
|
// Only the blocking worker touches PipeWire objects. The session
|
||||||
|
// remains owned here so it can be closed even if capture panics.
|
||||||
|
let session = Arc::new(session);
|
||||||
|
let capture_session = session.clone();
|
||||||
|
let result =
|
||||||
|
tokio::task::spawn_blocking(move || pipewire::run(&capture_session, &consumer))
|
||||||
|
.await;
|
||||||
|
let error = match result {
|
||||||
|
Ok(Ok(())) => None,
|
||||||
|
Ok(Err(error)) => Some(error),
|
||||||
|
Err(error) => Some(format!("Capture worker failed: {error}")),
|
||||||
|
};
|
||||||
|
if let Some(error) = error {
|
||||||
|
shared_thread.lock().unwrap().status = Status::Failed(error);
|
||||||
|
}
|
||||||
|
if let Err(error) = session.session.close().await {
|
||||||
|
log::warn!("closing capture session: {error}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
shared_thread.lock().unwrap().status = Status::Failed(format!("portal: {error}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
shared
|
||||||
|
}
|
||||||
@@ -0,0 +1,371 @@
|
|||||||
|
//! PipeWire stream consumer: receives screen frames from the portal remote and
|
||||||
|
//! publishes the latest one (RGBA8) into the shared slot.
|
||||||
|
|
||||||
|
use pipewire as pw;
|
||||||
|
use pw::properties::properties;
|
||||||
|
use pw::spa;
|
||||||
|
use spa::param::video::{VideoFormat, VideoInfoRaw};
|
||||||
|
|
||||||
|
use super::portal::PortalSession;
|
||||||
|
use super::{Frame, SharedCapture, Status};
|
||||||
|
|
||||||
|
struct UserData {
|
||||||
|
info: VideoInfoRaw,
|
||||||
|
have_format: bool,
|
||||||
|
seq: u64,
|
||||||
|
/// Reusable RGBA8 conversion buffer (swapped into the shared slot).
|
||||||
|
buf: Vec<u8>,
|
||||||
|
logged_bad_format: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn run(session: &PortalSession, shared: &SharedCapture) -> Result<(), String> {
|
||||||
|
pw::init();
|
||||||
|
|
||||||
|
let node_id = session.stream.pipe_wire_node_id();
|
||||||
|
|
||||||
|
let mainloop = pw::main_loop::MainLoopBox::new(None).map_err(|e| e.to_string())?;
|
||||||
|
let context =
|
||||||
|
pw::context::ContextBox::new(mainloop.loop_(), None).map_err(|e| e.to_string())?;
|
||||||
|
let core = context
|
||||||
|
.connect_fd(session.fd.try_clone().map_err(|e| e.to_string())?, None)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let data = UserData {
|
||||||
|
info: VideoInfoRaw::default(),
|
||||||
|
have_format: false,
|
||||||
|
seq: 0,
|
||||||
|
buf: Vec::new(),
|
||||||
|
logged_bad_format: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
let stream = pw::stream::StreamBox::new(
|
||||||
|
&core,
|
||||||
|
"whoshue-capture",
|
||||||
|
properties! {
|
||||||
|
*pw::keys::MEDIA_TYPE => "Video",
|
||||||
|
*pw::keys::MEDIA_CATEGORY => "Capture",
|
||||||
|
*pw::keys::MEDIA_ROLE => "Screen",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let shared_cb = shared.clone();
|
||||||
|
let state_shared = shared.clone();
|
||||||
|
let _listener = stream
|
||||||
|
.add_local_listener_with_user_data(data)
|
||||||
|
.state_changed(move |_, _, old, new| {
|
||||||
|
let message = match new {
|
||||||
|
pw::stream::StreamState::Error(error) => Some(format!("PipeWire: {error}")),
|
||||||
|
pw::stream::StreamState::Unconnected
|
||||||
|
if !matches!(old, pw::stream::StreamState::Unconnected) =>
|
||||||
|
{
|
||||||
|
Some("Capture source disconnected. Choose a source again.".into())
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
if let Some(message) = message {
|
||||||
|
state_shared.lock().unwrap().status = Status::Failed(message);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.param_changed(|_, ud, id, param| {
|
||||||
|
let Some(param) = param else { return };
|
||||||
|
if id != spa::param::ParamType::Format.as_raw() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let (media_type, media_subtype) = match spa::param::format_utils::parse_format(param) {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(_) => return,
|
||||||
|
};
|
||||||
|
if media_type != spa::param::format::MediaType::Video
|
||||||
|
|| media_subtype != spa::param::format::MediaSubtype::Raw
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ud.info.parse(param).is_ok() {
|
||||||
|
ud.have_format = true;
|
||||||
|
log::info!(
|
||||||
|
"negotiated format {:?} {}x{}",
|
||||||
|
ud.info.format(),
|
||||||
|
ud.info.size().width,
|
||||||
|
ud.info.size().height
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.process(move |stream, ud| {
|
||||||
|
// Keep every delivered frame: dropping an update on a static source
|
||||||
|
// can otherwise leave the last displayed pixels stale indefinitely.
|
||||||
|
let Some(mut buffer) = stream.dequeue_buffer() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if !ud.have_format {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let fmt = ud.info.format();
|
||||||
|
let size = ud.info.size();
|
||||||
|
let (w, h) = (size.width as usize, size.height as usize);
|
||||||
|
if w == 0 || h == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let datas = buffer.datas_mut();
|
||||||
|
if datas.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let chunk = datas[0].chunk();
|
||||||
|
let (offset, stride, chunk_size) = (
|
||||||
|
chunk.offset() as usize,
|
||||||
|
chunk.stride() as usize,
|
||||||
|
chunk.size() as usize,
|
||||||
|
);
|
||||||
|
if chunk_size == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Some(raw) = datas[0].data() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if offset + chunk_size > raw.len() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let raw = &raw[offset..offset + chunk_size];
|
||||||
|
|
||||||
|
if convert_to_rgba(fmt, raw, stride, w, h, &mut ud.buf).is_err() {
|
||||||
|
if !ud.logged_bad_format {
|
||||||
|
log::warn!("unsupported/invalid frame format {fmt:?}, frames dropped");
|
||||||
|
ud.logged_bad_format = true;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ud.seq += 1;
|
||||||
|
|
||||||
|
let mut guard = shared_cb.lock().unwrap();
|
||||||
|
match &mut guard.frame {
|
||||||
|
Some(f) => {
|
||||||
|
f.seq = ud.seq;
|
||||||
|
f.width = w as u32;
|
||||||
|
f.height = h as u32;
|
||||||
|
std::mem::swap(&mut f.rgba, &mut ud.buf);
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
guard.frame = Some(Frame {
|
||||||
|
seq: ud.seq,
|
||||||
|
width: w as u32,
|
||||||
|
height: h as u32,
|
||||||
|
rgba: std::mem::take(&mut ud.buf),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
guard.status = Status::Streaming {
|
||||||
|
width: w as u32,
|
||||||
|
height: h as u32,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.register()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let obj = spa::pod::object!(
|
||||||
|
spa::utils::SpaTypes::ObjectParamFormat,
|
||||||
|
spa::param::ParamType::EnumFormat,
|
||||||
|
spa::pod::property!(
|
||||||
|
spa::param::format::FormatProperties::MediaType,
|
||||||
|
Id,
|
||||||
|
spa::param::format::MediaType::Video
|
||||||
|
),
|
||||||
|
spa::pod::property!(
|
||||||
|
spa::param::format::FormatProperties::MediaSubtype,
|
||||||
|
Id,
|
||||||
|
spa::param::format::MediaSubtype::Raw
|
||||||
|
),
|
||||||
|
spa::pod::property!(
|
||||||
|
spa::param::format::FormatProperties::VideoFormat,
|
||||||
|
Choice,
|
||||||
|
Enum,
|
||||||
|
Id,
|
||||||
|
VideoFormat::BGRA,
|
||||||
|
VideoFormat::BGRA,
|
||||||
|
VideoFormat::BGRx,
|
||||||
|
VideoFormat::RGBA,
|
||||||
|
VideoFormat::RGBx,
|
||||||
|
VideoFormat::ARGB,
|
||||||
|
VideoFormat::xRGB,
|
||||||
|
VideoFormat::ABGR,
|
||||||
|
VideoFormat::xBGR,
|
||||||
|
VideoFormat::RGB,
|
||||||
|
VideoFormat::BGR
|
||||||
|
),
|
||||||
|
spa::pod::property!(
|
||||||
|
spa::param::format::FormatProperties::VideoSize,
|
||||||
|
Choice,
|
||||||
|
Range,
|
||||||
|
Rectangle,
|
||||||
|
spa::utils::Rectangle {
|
||||||
|
width: 2560,
|
||||||
|
height: 1440
|
||||||
|
},
|
||||||
|
spa::utils::Rectangle {
|
||||||
|
width: 1,
|
||||||
|
height: 1
|
||||||
|
},
|
||||||
|
spa::utils::Rectangle {
|
||||||
|
width: 8192,
|
||||||
|
height: 8192
|
||||||
|
}
|
||||||
|
),
|
||||||
|
spa::pod::property!(
|
||||||
|
spa::param::format::FormatProperties::VideoFramerate,
|
||||||
|
Choice,
|
||||||
|
Range,
|
||||||
|
Fraction,
|
||||||
|
spa::utils::Fraction { num: 15, denom: 1 },
|
||||||
|
spa::utils::Fraction { num: 0, denom: 1 },
|
||||||
|
spa::utils::Fraction { num: 30, denom: 1 }
|
||||||
|
),
|
||||||
|
);
|
||||||
|
let values: Vec<u8> = spa::pod::serialize::PodSerializer::serialize(
|
||||||
|
std::io::Cursor::new(Vec::new()),
|
||||||
|
&spa::pod::Value::Object(obj),
|
||||||
|
)
|
||||||
|
.expect("serialize format pod")
|
||||||
|
.0
|
||||||
|
.into_inner();
|
||||||
|
|
||||||
|
let mut params = [spa::pod::Pod::from_bytes(&values).expect("format pod")];
|
||||||
|
|
||||||
|
stream
|
||||||
|
.connect(
|
||||||
|
spa::utils::Direction::Input,
|
||||||
|
Some(node_id),
|
||||||
|
pw::stream::StreamFlags::AUTOCONNECT | pw::stream::StreamFlags::MAP_BUFFERS,
|
||||||
|
&mut params,
|
||||||
|
)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
log::info!("pipewire stream connected (node {node_id})");
|
||||||
|
while {
|
||||||
|
let guard = shared.lock().unwrap();
|
||||||
|
!guard.stop && !matches!(guard.status, Status::Failed(_))
|
||||||
|
} {
|
||||||
|
mainloop.loop_().iterate(pw::loop_::Timeout::Finite(
|
||||||
|
std::time::Duration::from_millis(100),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert one frame of the negotiated format into tightly packed RGBA8.
|
||||||
|
fn convert_to_rgba(
|
||||||
|
fmt: VideoFormat,
|
||||||
|
raw: &[u8],
|
||||||
|
stride: usize,
|
||||||
|
w: usize,
|
||||||
|
h: usize,
|
||||||
|
out: &mut Vec<u8>,
|
||||||
|
) -> Result<(), ()> {
|
||||||
|
let bpp: usize = match fmt {
|
||||||
|
f if f == VideoFormat::RGB || f == VideoFormat::BGR => 3,
|
||||||
|
f if [
|
||||||
|
VideoFormat::RGBx,
|
||||||
|
VideoFormat::BGRx,
|
||||||
|
VideoFormat::xRGB,
|
||||||
|
VideoFormat::xBGR,
|
||||||
|
VideoFormat::RGBA,
|
||||||
|
VideoFormat::BGRA,
|
||||||
|
VideoFormat::ARGB,
|
||||||
|
VideoFormat::ABGR,
|
||||||
|
]
|
||||||
|
.contains(&f) =>
|
||||||
|
{
|
||||||
|
4
|
||||||
|
}
|
||||||
|
_ => return Err(()),
|
||||||
|
};
|
||||||
|
if w == 0 || h == 0 {
|
||||||
|
return Err(());
|
||||||
|
}
|
||||||
|
if stride < w * bpp || raw.len() < stride * (h - 1) + w * bpp {
|
||||||
|
return Err(());
|
||||||
|
}
|
||||||
|
|
||||||
|
out.resize(w * h * 4, 0);
|
||||||
|
for y in 0..h {
|
||||||
|
let src = &raw[y * stride..y * stride + w * bpp];
|
||||||
|
let dst = &mut out[y * w * 4..(y + 1) * w * 4];
|
||||||
|
if bpp == 3 {
|
||||||
|
for (s, d) in src
|
||||||
|
.as_chunks::<3>()
|
||||||
|
.0
|
||||||
|
.iter()
|
||||||
|
.zip(dst.as_chunks_mut::<4>().0.iter_mut())
|
||||||
|
{
|
||||||
|
let (r, g, b) = if fmt == VideoFormat::RGB {
|
||||||
|
(s[0], s[1], s[2])
|
||||||
|
} else {
|
||||||
|
(s[2], s[1], s[0])
|
||||||
|
};
|
||||||
|
d.copy_from_slice(&[r, g, b, 255]);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (s, d) in src
|
||||||
|
.as_chunks::<4>()
|
||||||
|
.0
|
||||||
|
.iter()
|
||||||
|
.zip(dst.as_chunks_mut::<4>().0.iter_mut())
|
||||||
|
{
|
||||||
|
let (r, g, b, a) = if fmt == VideoFormat::RGBx || fmt == VideoFormat::RGBA {
|
||||||
|
(s[0], s[1], s[2], s[3])
|
||||||
|
} else if fmt == VideoFormat::BGRx || fmt == VideoFormat::BGRA {
|
||||||
|
(s[2], s[1], s[0], s[3])
|
||||||
|
} else if fmt == VideoFormat::xRGB || fmt == VideoFormat::ARGB {
|
||||||
|
(s[1], s[2], s[3], s[0])
|
||||||
|
} else {
|
||||||
|
// xBGR / ABGR
|
||||||
|
(s[3], s[2], s[1], s[0])
|
||||||
|
};
|
||||||
|
let a = if fmt == VideoFormat::RGBA
|
||||||
|
|| fmt == VideoFormat::BGRA
|
||||||
|
|| fmt == VideoFormat::ARGB
|
||||||
|
|| fmt == VideoFormat::ABGR
|
||||||
|
{
|
||||||
|
a
|
||||||
|
} else {
|
||||||
|
255
|
||||||
|
};
|
||||||
|
d.copy_from_slice(&[r, g, b, a]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn converts_padded_rows_and_ignores_padding_alpha() {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
convert_to_rgba(
|
||||||
|
VideoFormat::BGRx,
|
||||||
|
&[3, 2, 1, 0, 99, 99, 99, 99, 6, 5, 4, 0],
|
||||||
|
8,
|
||||||
|
1,
|
||||||
|
2,
|
||||||
|
&mut out,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(out, [1, 2, 3, 255, 4, 5, 6, 255]);
|
||||||
|
convert_to_rgba(VideoFormat::ARGB, &[128, 1, 2, 3], 4, 1, 1, &mut out).unwrap();
|
||||||
|
assert_eq!(out, [1, 2, 3, 128]);
|
||||||
|
convert_to_rgba(VideoFormat::BGR, &[3, 2, 1], 3, 1, 1, &mut out).unwrap();
|
||||||
|
assert_eq!(out, [1, 2, 3, 255]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_short_buffers_stride_and_zero_dimensions() {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
assert!(convert_to_rgba(VideoFormat::RGBA, &[0; 7], 4, 1, 2, &mut out).is_err());
|
||||||
|
assert!(convert_to_rgba(VideoFormat::RGBA, &[0; 8], 3, 1, 2, &mut out).is_err());
|
||||||
|
assert!(convert_to_rgba(VideoFormat::RGBA, &[], 4, 1, 0, &mut out).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
//! XDG Desktop Portal ScreenCast session setup.
|
||||||
|
|
||||||
|
use std::os::fd::OwnedFd;
|
||||||
|
|
||||||
|
use ashpd::desktop::Session;
|
||||||
|
use ashpd::desktop::{
|
||||||
|
PersistMode,
|
||||||
|
screencast::{CursorMode, Screencast, SelectSourcesOptions, SourceType, Stream},
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::{SharedCapture, Status};
|
||||||
|
|
||||||
|
/// Everything the PipeWire side needs, plus the session guard that must stay
|
||||||
|
/// alive for the stream to keep flowing.
|
||||||
|
pub struct PortalSession {
|
||||||
|
pub(super) session: Session<Screencast>,
|
||||||
|
pub stream: Stream,
|
||||||
|
pub fd: OwnedFd,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn connect(
|
||||||
|
restore_token: Option<String>,
|
||||||
|
shared: &SharedCapture,
|
||||||
|
) -> ashpd::Result<PortalSession> {
|
||||||
|
let proxy = Screencast::new().await?;
|
||||||
|
let session = proxy.create_session(Default::default()).await?;
|
||||||
|
|
||||||
|
// Keep the session available for explicit cleanup if the picker is cancelled
|
||||||
|
// or a new source is selected while the permission dialog is still open.
|
||||||
|
let result = {
|
||||||
|
let setup = async {
|
||||||
|
let mut options = SelectSourcesOptions::default()
|
||||||
|
.set_cursor_mode(CursorMode::Hidden)
|
||||||
|
.set_sources(Some(SourceType::Window | SourceType::Monitor))
|
||||||
|
.set_multiple(false)
|
||||||
|
.set_persist_mode(PersistMode::ExplicitlyRevoked);
|
||||||
|
if let Some(token) = restore_token.as_deref() {
|
||||||
|
options = options.set_restore_token(Some(token));
|
||||||
|
}
|
||||||
|
proxy.select_sources(&session, options).await?;
|
||||||
|
|
||||||
|
{
|
||||||
|
shared.lock().unwrap().status = Status::WaitingForPermission;
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = proxy
|
||||||
|
.start(&session, None, Default::default())
|
||||||
|
.await?
|
||||||
|
.response()?;
|
||||||
|
|
||||||
|
let stream = response
|
||||||
|
.streams()
|
||||||
|
.first()
|
||||||
|
.cloned()
|
||||||
|
.ok_or_else(|| ashpd::Error::NoResponse)?;
|
||||||
|
|
||||||
|
if let Some(token) = response.restore_token() {
|
||||||
|
shared.lock().unwrap().new_restore_token = Some(token.to_owned());
|
||||||
|
}
|
||||||
|
|
||||||
|
let fd = proxy
|
||||||
|
.open_pipe_wire_remote(&session, Default::default())
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok::<_, ashpd::Error>((stream, fd))
|
||||||
|
};
|
||||||
|
tokio::pin!(setup);
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
result = &mut setup => break result,
|
||||||
|
_ = tokio::time::sleep(std::time::Duration::from_millis(100)) => {
|
||||||
|
if shared.lock().unwrap().stop { break Err(ashpd::Error::NoResponse); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let (stream, fd) = match result {
|
||||||
|
Ok(result) => result,
|
||||||
|
Err(error) => {
|
||||||
|
let _ = session.close().await;
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(PortalSession {
|
||||||
|
session,
|
||||||
|
stream,
|
||||||
|
fd,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
//! Persistent config at `~/.config/whoshue/config.toml`.
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||||
|
pub struct Config {
|
||||||
|
/// XDG portal restore token for the (persisted) screencast session.
|
||||||
|
/// When valid, KDE skips the permission dialog on startup.
|
||||||
|
pub restore_token: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn config_path() -> PathBuf {
|
||||||
|
let base = std::env::var_os("XDG_CONFIG_HOME")
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
PathBuf::from(std::env::var_os("HOME").expect("HOME not set")).join(".config")
|
||||||
|
});
|
||||||
|
base.join("whoshue").join("config.toml")
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Config {
|
||||||
|
pub fn load() -> Self {
|
||||||
|
let path = config_path();
|
||||||
|
match std::fs::read_to_string(&path) {
|
||||||
|
Ok(text) => toml::from_str(&text).unwrap_or_else(|e| {
|
||||||
|
log::warn!("ignoring invalid config {}: {e}", path.display());
|
||||||
|
Self::default()
|
||||||
|
}),
|
||||||
|
Err(_) => Self::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save(&self) {
|
||||||
|
let path = config_path();
|
||||||
|
if let Some(parent) = path.parent()
|
||||||
|
&& let Err(e) = std::fs::create_dir_all(parent)
|
||||||
|
{
|
||||||
|
log::warn!("cannot create {}: {e}", parent.display());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
match toml::to_string_pretty(self) {
|
||||||
|
Ok(text) => {
|
||||||
|
if let Err(e) = std::fs::write(&path, &text) {
|
||||||
|
log::warn!("cannot write {}: {e}", path.display());
|
||||||
|
} else {
|
||||||
|
log::debug!("wrote config ({} bytes) to {}", text.len(), path.display());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => log::warn!("cannot serialize config: {e}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
//! Native KDE file selection via the XDG portal, without blocking the UI.
|
||||||
|
use ashpd::desktop::{
|
||||||
|
ResponseError,
|
||||||
|
file_chooser::{FileFilter, SelectedFiles},
|
||||||
|
};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
pub type Selection = Result<Option<PathBuf>, String>;
|
||||||
|
|
||||||
|
fn local_path(uri: &str) -> Result<PathBuf, String> {
|
||||||
|
url::Url::parse(uri)
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.to_file_path()
|
||||||
|
.map_err(|()| "Please select a local image file.".into())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn open_image() -> Selection {
|
||||||
|
let result = async {
|
||||||
|
SelectedFiles::open_file()
|
||||||
|
.title("Open image")
|
||||||
|
.accept_label("Open")
|
||||||
|
.multiple(false)
|
||||||
|
.modal(true)
|
||||||
|
.filter(FileFilter::new("Images").mimetype("image/*"))
|
||||||
|
.filter(FileFilter::new("All files").glob("*"))
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.response()
|
||||||
|
}
|
||||||
|
.await;
|
||||||
|
match result {
|
||||||
|
Ok(files) => files
|
||||||
|
.uris()
|
||||||
|
.first()
|
||||||
|
.map(|uri| local_path(uri.as_str()))
|
||||||
|
.transpose(),
|
||||||
|
Err(ashpd::Error::Response(ResponseError::Cancelled)) => Ok(None),
|
||||||
|
Err(error) => Err(format!("Could not open file picker: {error}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
#[test]
|
||||||
|
fn decodes_file_paths_without_losing_spaces_or_unicode() {
|
||||||
|
assert_eq!(
|
||||||
|
local_path("file:///tmp/pixel%20art%20%23%20gr%C3%BCn.png").unwrap(),
|
||||||
|
PathBuf::from("/tmp/pixel art # grün.png")
|
||||||
|
);
|
||||||
|
assert!(local_path("https://example.com/image.png").is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
+78
@@ -0,0 +1,78 @@
|
|||||||
|
mod analysis;
|
||||||
|
mod app;
|
||||||
|
mod capture;
|
||||||
|
mod config;
|
||||||
|
mod file_dialog;
|
||||||
|
mod runtime;
|
||||||
|
mod ui;
|
||||||
|
|
||||||
|
use eframe::egui;
|
||||||
|
|
||||||
|
/// Wait for the first captured frame, dump it as PNG, exit.
|
||||||
|
/// `whoshue --selftest [path.png]`
|
||||||
|
fn selftest() -> i32 {
|
||||||
|
let path = std::env::args()
|
||||||
|
.nth(2)
|
||||||
|
.unwrap_or_else(|| "/tmp/whoshue-selftest.png".into());
|
||||||
|
let config = config::Config::load();
|
||||||
|
let capture = capture::spawn(config.restore_token.clone());
|
||||||
|
|
||||||
|
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
|
||||||
|
loop {
|
||||||
|
{
|
||||||
|
let guard = capture.lock().unwrap();
|
||||||
|
if let Some(frame) = &guard.frame {
|
||||||
|
match image::save_buffer(
|
||||||
|
&path,
|
||||||
|
&frame.rgba,
|
||||||
|
frame.width,
|
||||||
|
frame.height,
|
||||||
|
image::ColorType::Rgba8,
|
||||||
|
) {
|
||||||
|
Ok(()) => {
|
||||||
|
println!(
|
||||||
|
"OK: frame {} ({}x{}) saved to {path}",
|
||||||
|
frame.seq, frame.width, frame.height
|
||||||
|
);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("save failed: {e}");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let capture::Status::Failed(e) = &guard.status {
|
||||||
|
eprintln!("capture failed: {e}");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if std::time::Instant::now() > deadline {
|
||||||
|
eprintln!("timed out waiting for a frame");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() -> eframe::Result<()> {
|
||||||
|
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
|
||||||
|
|
||||||
|
if std::env::args().nth(1).as_deref() == Some("--selftest") {
|
||||||
|
std::process::exit(selftest());
|
||||||
|
}
|
||||||
|
|
||||||
|
let options = eframe::NativeOptions {
|
||||||
|
viewport: egui::ViewportBuilder::default()
|
||||||
|
.with_title("whoshue")
|
||||||
|
.with_app_id("whoshue")
|
||||||
|
.with_inner_size([1200.0, 800.0]),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
eframe::run_native(
|
||||||
|
"whoshue",
|
||||||
|
options,
|
||||||
|
Box::new(|cc| Ok(Box::new(app::WhosHueApp::new(cc)))),
|
||||||
|
)
|
||||||
|
}
|
||||||
+114
@@ -0,0 +1,114 @@
|
|||||||
|
//! Portal connections cache background tasks. Their executor must outlive each
|
||||||
|
//! capture or dialog, and must never run a blocking PipeWire loop directly.
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
use tokio::runtime::Runtime;
|
||||||
|
|
||||||
|
pub fn get() -> &'static Runtime {
|
||||||
|
static RUNTIME: OnceLock<Runtime> = OnceLock::new();
|
||||||
|
RUNTIME.get_or_init(|| {
|
||||||
|
tokio::runtime::Builder::new_multi_thread()
|
||||||
|
.worker_threads(2)
|
||||||
|
.thread_name("whoshue-portal")
|
||||||
|
.enable_all()
|
||||||
|
.build()
|
||||||
|
.expect("portal runtime")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::sync::mpsc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn portal_tasks_survive_requests_and_continue_during_blocking_capture() {
|
||||||
|
let (ticks, received) = mpsc::channel();
|
||||||
|
let background = get().spawn(async move {
|
||||||
|
loop {
|
||||||
|
tokio::time::sleep(Duration::from_millis(5)).await;
|
||||||
|
if ticks.send(()).is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
for _ in 0..3 {
|
||||||
|
let (stop, stopped) = mpsc::channel();
|
||||||
|
let job = get().spawn(async move {
|
||||||
|
tokio::task::spawn_blocking(move || stopped.recv_timeout(Duration::from_secs(2)))
|
||||||
|
.await
|
||||||
|
});
|
||||||
|
received
|
||||||
|
.recv_timeout(Duration::from_secs(1))
|
||||||
|
.expect("portal executor stalled");
|
||||||
|
stop.send(()).unwrap();
|
||||||
|
get().block_on(job).unwrap().unwrap().unwrap();
|
||||||
|
}
|
||||||
|
background.abort();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod portal_regression {
|
||||||
|
use super::*;
|
||||||
|
use std::{process::Command, sync::mpsc, time::Duration};
|
||||||
|
|
||||||
|
struct ScreenCast;
|
||||||
|
|
||||||
|
#[zbus::interface(name = "org.freedesktop.portal.ScreenCast")]
|
||||||
|
impl ScreenCast {
|
||||||
|
#[zbus(property, name = "version")]
|
||||||
|
fn version(&self) -> u32 {
|
||||||
|
5
|
||||||
|
}
|
||||||
|
|
||||||
|
#[zbus(property)]
|
||||||
|
fn available_source_types(&self) -> u32 {
|
||||||
|
3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run in a separate process with a private D-Bus: never replace the user's
|
||||||
|
// real portal and never require an interactive desktop for this regression.
|
||||||
|
#[test]
|
||||||
|
fn cached_portal_works_during_capture_and_after_restart() {
|
||||||
|
const CHILD: &str = "WHOSHUE_PRIVATE_PORTAL_TEST";
|
||||||
|
if std::env::var_os(CHILD).is_none() {
|
||||||
|
let status = Command::new("dbus-run-session")
|
||||||
|
.arg("--").arg(std::env::current_exe().unwrap())
|
||||||
|
.args(["--exact", "runtime::portal_regression::cached_portal_works_during_capture_and_after_restart", "--nocapture"])
|
||||||
|
.env(CHILD,"1").status().expect("dbus-run-session is required for the portal regression test");
|
||||||
|
assert!(status.success());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let _service = get().block_on(async {
|
||||||
|
zbus::connection::Builder::session()
|
||||||
|
.unwrap()
|
||||||
|
.name("org.freedesktop.portal.Desktop")
|
||||||
|
.unwrap()
|
||||||
|
.serve_at("/org/freedesktop/portal/desktop", ScreenCast)
|
||||||
|
.unwrap()
|
||||||
|
.build()
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
});
|
||||||
|
for _ in 0..3 {
|
||||||
|
let (stop, stopped) = mpsc::channel();
|
||||||
|
let capture =
|
||||||
|
get().spawn_blocking(move || stopped.recv_timeout(Duration::from_secs(3)));
|
||||||
|
let request = get().spawn(async {
|
||||||
|
tokio::time::timeout(Duration::from_secs(2), async {
|
||||||
|
let portal = ashpd::desktop::screencast::Screencast::new().await.unwrap();
|
||||||
|
assert_eq!(portal.version(), 5);
|
||||||
|
let sources = portal.available_source_types().await.unwrap();
|
||||||
|
assert!(sources.contains(ashpd::desktop::screencast::SourceType::Window));
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("cached portal connection stopped responding");
|
||||||
|
});
|
||||||
|
get().block_on(request).unwrap();
|
||||||
|
stop.send(()).unwrap();
|
||||||
|
get().block_on(capture).unwrap().unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+111
@@ -0,0 +1,111 @@
|
|||||||
|
use crate::analysis::{Analysis, HUE_BINS, Region};
|
||||||
|
use eframe::egui::{self, Color32, Pos2, Rect};
|
||||||
|
|
||||||
|
/// Map a drag in a displayed image to a nonempty, source-pixel-aligned crop.
|
||||||
|
pub fn selection(a: Pos2, b: Pos2, image: Rect, width: u32, height: u32) -> Region {
|
||||||
|
let point = |p: Pos2| {
|
||||||
|
egui::pos2(
|
||||||
|
((p.x - image.left()) / image.width() * width as f32).clamp(0.0, width as f32),
|
||||||
|
((p.y - image.top()) / image.height() * height as f32).clamp(0.0, height as f32),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
let a = point(a);
|
||||||
|
let b = point(b);
|
||||||
|
let x = (a.x.min(b.x).floor() as u32).min(width - 1);
|
||||||
|
let y = (a.y.min(b.y).floor() as u32).min(height - 1);
|
||||||
|
Region {
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
width: (a.x.max(b.x).ceil() as u32).max(x + 1).min(width) - x,
|
||||||
|
height: (a.y.max(b.y).ceil() as u32).max(y + 1).min(height) - y,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn histogram(ui: &mut egui::Ui, analysis: &Analysis, distinct: bool) {
|
||||||
|
let values = if distinct {
|
||||||
|
&analysis.hue_colors
|
||||||
|
} else {
|
||||||
|
&analysis.hue_pixels
|
||||||
|
};
|
||||||
|
let max = values.iter().copied().max().unwrap_or(0).max(1) as f32;
|
||||||
|
let (rect, _) = ui.allocate_exact_size(
|
||||||
|
egui::vec2(ui.available_width(), 110.0),
|
||||||
|
egui::Sense::hover(),
|
||||||
|
);
|
||||||
|
for (bin, value) in values.iter().enumerate() {
|
||||||
|
let x = rect.left() + rect.width() * bin as f32 / HUE_BINS as f32;
|
||||||
|
let bar = Rect::from_min_max(
|
||||||
|
egui::pos2(x, rect.bottom() - 100.0 * *value as f32 / max),
|
||||||
|
egui::pos2(x + rect.width() / HUE_BINS as f32 - 1.0, rect.bottom()),
|
||||||
|
);
|
||||||
|
let color: Color32 =
|
||||||
|
egui::ecolor::Hsva::new(bin as f32 / HUE_BINS as f32, 0.85, 0.9, 1.0).into();
|
||||||
|
ui.painter().rect_filled(bar, 0.0, color);
|
||||||
|
let hit = Rect::from_min_max(
|
||||||
|
egui::pos2(x, rect.top()),
|
||||||
|
egui::pos2(x + rect.width() / HUE_BINS as f32, rect.bottom()),
|
||||||
|
);
|
||||||
|
ui.interact(hit, ui.id().with(bin), egui::Sense::hover())
|
||||||
|
.on_hover_text(format!(
|
||||||
|
"{}–{}°: {} {}",
|
||||||
|
bin * 10,
|
||||||
|
(bin + 1) * 10,
|
||||||
|
value,
|
||||||
|
if distinct { "colors" } else { "pixels" }
|
||||||
|
));
|
||||||
|
}
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.label("0° red");
|
||||||
|
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||||
|
ui.label("360° red");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
#[test]
|
||||||
|
fn reversed_scaled_drag_and_outside_edges() {
|
||||||
|
let rect = Rect::from_min_size(egui::pos2(10.0, 20.0), egui::vec2(200.0, 100.0));
|
||||||
|
assert_eq!(
|
||||||
|
selection(
|
||||||
|
egui::pos2(110.0, 70.0),
|
||||||
|
egui::pos2(30.0, 30.0),
|
||||||
|
rect,
|
||||||
|
100,
|
||||||
|
50
|
||||||
|
),
|
||||||
|
Region {
|
||||||
|
x: 10,
|
||||||
|
y: 5,
|
||||||
|
width: 40,
|
||||||
|
height: 20
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
selection(
|
||||||
|
egui::pos2(-20.0, -20.0),
|
||||||
|
egui::pos2(500.0, 500.0),
|
||||||
|
rect,
|
||||||
|
100,
|
||||||
|
50
|
||||||
|
),
|
||||||
|
Region {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: 100,
|
||||||
|
height: 50
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
selection(rect.max, rect.max, rect, 100, 50),
|
||||||
|
Region {
|
||||||
|
x: 99,
|
||||||
|
y: 49,
|
||||||
|
width: 1,
|
||||||
|
height: 1
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user