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