This commit is contained in:
Schluffe
2026-09-27 23:13:26 +02:00
commit 72f481cc24
14 changed files with 7216 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
/target
Generated
+5396
View File
File diff suppressed because it is too large Load Diff
+22
View File
@@ -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"] }
+80
View File
@@ -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
```
+171
View File
@@ -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
View File
@@ -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();
}
}
+79
View File
@@ -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
}
+371
View File
@@ -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());
}
}
+90
View File
@@ -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,
})
}
+54
View File
@@ -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}"),
}
}
}
+53
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
}
);
}
}