feat: valw edit
This commit is contained in:
1 parent
6ffb36d49f
commit
c28330fecb
7 files changed
+2595
-60
No files matched your search
Generated
+2163
-45
File diff suppressed because it is too large.
Load diff
@@ -9,6 +9,7 @@ license = "MIT"
|
|||||||
anyhow = "1.0.104"
|
anyhow = "1.0.104"
|
||||||
chrono = "0.4.45"
|
chrono = "0.4.45"
|
||||||
clap = { version = "4.6.7", features = ["derive"] }
|
clap = { version = "4.6.7", features = ["derive"] }
|
||||||
|
eframe = { version = "0.36.2", default-features = false, features = ["default_fonts", "glow", "wayland"] }
|
||||||
glow = "0.18.0"
|
glow = "0.18.0"
|
||||||
image = { version = "0.25.10", default-features = false, features = ["png"] }
|
image = { version = "0.25.10", default-features = false, features = ["png"] }
|
||||||
khronos-egl = { version = "6.0.0", features = ["dynamic"] }
|
khronos-egl = { version = "6.0.0", features = ["dynamic"] }
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{ ... }:
|
{ ... }:
|
||||||
{
|
{
|
||||||
perSystem =
|
perSystem =
|
||||||
{ pkgs, self', ... }:
|
{ pkgs, lib, self', ... }:
|
||||||
{
|
{
|
||||||
devShells.default = pkgs.mkShell {
|
devShells.default = pkgs.mkShell {
|
||||||
inputsFrom = [ self'.packages.default ];
|
inputsFrom = [ self'.packages.default ];
|
||||||
@@ -17,6 +17,13 @@
|
|||||||
wayland-utils
|
wayland-utils
|
||||||
];
|
];
|
||||||
|
|
||||||
|
# What the package gets through its RUNPATH, for `cargo run`.
|
||||||
|
LD_LIBRARY_PATH = lib.makeLibraryPath [
|
||||||
|
pkgs.wayland
|
||||||
|
pkgs.libxkbcommon
|
||||||
|
pkgs.libglvnd
|
||||||
|
];
|
||||||
|
|
||||||
# The repo is a path: flake, so Nix copies the whole tree on every
|
# The repo is a path: flake, so Nix copies the whole tree on every
|
||||||
# evaluation. Keep the multi-gigabyte target dir out of it.
|
# evaluation. Keep the multi-gigabyte target dir out of it.
|
||||||
shellHook = ''
|
shellHook = ''
|
||||||
|
|||||||
+14
-5
@@ -33,12 +33,21 @@
|
|||||||
# Tests run through `nix flake check` instead.
|
# Tests run through `nix flake check` instead.
|
||||||
doCheck = false;
|
doCheck = false;
|
||||||
|
|
||||||
# Clicking a preview opens Satty. --suffix keeps a user's own Satty first.
|
# Clicking a preview opens Satty if configured. --suffix keeps a user's own Satty first.
|
||||||
# Zoom loads libEGL.so.1 (libglvnd); Mesa's drivers come from the system.
|
|
||||||
postInstall = ''
|
postInstall = ''
|
||||||
wrapProgram $out/bin/valw \
|
wrapProgram $out/bin/valw --suffix PATH : ${lib.makeBinPath [ pkgs.satty ]}
|
||||||
--suffix PATH : ${lib.makeBinPath [ pkgs.satty ]} \
|
'';
|
||||||
--suffix LD_LIBRARY_PATH : ${lib.makeLibraryPath [ pkgs.libglvnd ]}
|
|
||||||
|
# dlopen()ed at run time: libwayland (winit, valw), libxkbcommon (winit),
|
||||||
|
# libEGL (zoom, editor; Mesa's drivers come from the system).
|
||||||
|
postFixup = ''
|
||||||
|
patchelf --add-rpath ${
|
||||||
|
lib.makeLibraryPath [
|
||||||
|
pkgs.wayland
|
||||||
|
pkgs.libxkbcommon
|
||||||
|
pkgs.libglvnd
|
||||||
|
]
|
||||||
|
} $out/bin/.valw-wrapped
|
||||||
'';
|
'';
|
||||||
|
|
||||||
meta = {
|
meta = {
|
||||||
|
|||||||
@@ -4,3 +4,375 @@ pub mod doc;
|
|||||||
pub mod export;
|
pub mod export;
|
||||||
pub mod shape;
|
pub mod shape;
|
||||||
pub mod view;
|
pub mod view;
|
||||||
|
|
||||||
|
use std::io::Write;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use anyhow::{Context, Result, anyhow};
|
||||||
|
use eframe::egui::{
|
||||||
|
self, Color32, Key, Modifiers, Pos2, Rect, Sense, Stroke, StrokeKind, ViewportCommand,
|
||||||
|
};
|
||||||
|
use image::RgbaImage;
|
||||||
|
|
||||||
|
use self::doc::Doc;
|
||||||
|
use self::shape::{Drag, PALETTE, Prim, Style, Tool, WIDTHS, geometry};
|
||||||
|
use self::view::Fit;
|
||||||
|
|
||||||
|
const APP_ID: &str = "valw-editor";
|
||||||
|
const STATUS_FOR: Duration = Duration::from_secs(2);
|
||||||
|
|
||||||
|
pub fn run(path: &Path) -> Result<()> {
|
||||||
|
let image = crate::thumbnail::load(path)?;
|
||||||
|
let name = path
|
||||||
|
.file_name()
|
||||||
|
.map(|n| n.to_string_lossy().into_owned())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let (w, h) = image.dimensions();
|
||||||
|
let viewport = egui::ViewportBuilder::default()
|
||||||
|
.with_title(format!("{name} — valw"))
|
||||||
|
.with_app_id(APP_ID)
|
||||||
|
.with_inner_size([
|
||||||
|
(w as f32).clamp(480.0, 1600.0),
|
||||||
|
(h as f32 + 48.0).clamp(320.0, 1000.0),
|
||||||
|
]);
|
||||||
|
let options = eframe::NativeOptions {
|
||||||
|
viewport,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let path = path.to_path_buf();
|
||||||
|
eframe::run_native(
|
||||||
|
APP_ID,
|
||||||
|
options,
|
||||||
|
Box::new(move |cc| Ok(Box::new(Editor::new(&cc.egui_ctx, path, image)))),
|
||||||
|
)
|
||||||
|
.map_err(|e| anyhow!("the editor window failed: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Editor {
|
||||||
|
path: PathBuf,
|
||||||
|
base: RgbaImage,
|
||||||
|
texture: egui::TextureHandle,
|
||||||
|
doc: Doc,
|
||||||
|
tool: Tool,
|
||||||
|
color: usize,
|
||||||
|
width: usize,
|
||||||
|
drag: Option<Drag>,
|
||||||
|
status: Option<(String, Instant)>,
|
||||||
|
/// The unsaved-changes bar is showing.
|
||||||
|
confirm_close: bool,
|
||||||
|
/// The user chose to close; let the close through.
|
||||||
|
closing: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Editor {
|
||||||
|
fn new(ctx: &egui::Context, path: PathBuf, base: RgbaImage) -> Editor {
|
||||||
|
let size = [base.width() as usize, base.height() as usize];
|
||||||
|
let pixels = egui::ColorImage::from_rgba_unmultiplied(size, base.as_raw());
|
||||||
|
let texture = ctx.load_texture("image", pixels, egui::TextureOptions::LINEAR);
|
||||||
|
Editor {
|
||||||
|
path,
|
||||||
|
base,
|
||||||
|
texture,
|
||||||
|
doc: Doc::new(),
|
||||||
|
tool: Tool::Arrow,
|
||||||
|
color: 0,
|
||||||
|
width: 1,
|
||||||
|
drag: None,
|
||||||
|
status: None,
|
||||||
|
confirm_close: false,
|
||||||
|
closing: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn style(&self) -> Style {
|
||||||
|
Style {
|
||||||
|
color: PALETTE[self.color],
|
||||||
|
width: WIDTHS[self.width].1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn say(&mut self, text: impl Into<String>) {
|
||||||
|
self.status = Some((text.into(), Instant::now()));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn png(&self) -> Result<Vec<u8>> {
|
||||||
|
crate::output::encode_png(&export::render(&self.base, self.doc.shapes()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn save(&mut self, copy: bool) -> bool {
|
||||||
|
let target = if copy {
|
||||||
|
export::edited_path(&self.path)
|
||||||
|
} else {
|
||||||
|
self.path.clone()
|
||||||
|
};
|
||||||
|
match self
|
||||||
|
.png()
|
||||||
|
.and_then(|png| crate::output::write_atomic(&target, &png))
|
||||||
|
{
|
||||||
|
Ok(()) => {
|
||||||
|
if !copy {
|
||||||
|
self.doc.mark_saved();
|
||||||
|
}
|
||||||
|
tracing::info!("saved {}", target.display());
|
||||||
|
self.say(if copy { "Saved a copy" } else { "Saved" });
|
||||||
|
true
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("save failed: {e:#}");
|
||||||
|
self.say(format!("Could not save: {e}"));
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn copy(&mut self) {
|
||||||
|
let result = self.png().and_then(|png| {
|
||||||
|
use std::os::unix::process::CommandExt;
|
||||||
|
let exe = std::env::current_exe().context("no path to valw")?;
|
||||||
|
let mut command = std::process::Command::new(exe);
|
||||||
|
command
|
||||||
|
.arg("__clipboard")
|
||||||
|
.stdin(std::process::Stdio::piped())
|
||||||
|
.stdout(std::process::Stdio::null())
|
||||||
|
.stderr(std::process::Stdio::null());
|
||||||
|
// SAFETY: setsid is async-signal-safe.
|
||||||
|
unsafe {
|
||||||
|
command.pre_exec(|| rustix::process::setsid().map(drop).map_err(Into::into));
|
||||||
|
}
|
||||||
|
let mut child = command
|
||||||
|
.spawn()
|
||||||
|
.context("could not start the clipboard server")?;
|
||||||
|
child
|
||||||
|
.stdin
|
||||||
|
.take()
|
||||||
|
.context("no stdin")?
|
||||||
|
.write_all(&png)
|
||||||
|
.context("could not hand over the image")?;
|
||||||
|
Ok(())
|
||||||
|
});
|
||||||
|
match result {
|
||||||
|
Ok(()) => self.say("Copied"),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("copy failed: {e:#}");
|
||||||
|
self.say(format!("Could not copy: {e}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn close(&mut self, ctx: &egui::Context) {
|
||||||
|
self.closing = true;
|
||||||
|
ctx.send_viewport_cmd(ViewportCommand::Close);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn keys(&mut self, ctx: &egui::Context) {
|
||||||
|
let shift_cmd = Modifiers::COMMAND | Modifiers::SHIFT;
|
||||||
|
let pressed = |m: Modifiers, k: Key| ctx.input_mut(|i| i.consume_key(m, k));
|
||||||
|
// Most specific first: consume_key ignores extra Shift.
|
||||||
|
if pressed(shift_cmd, Key::S) {
|
||||||
|
self.save(true);
|
||||||
|
} else if pressed(Modifiers::COMMAND, Key::S) && self.save(false) && self.confirm_close {
|
||||||
|
self.close(ctx);
|
||||||
|
}
|
||||||
|
if pressed(shift_cmd, Key::Z) || pressed(Modifiers::COMMAND, Key::Y) {
|
||||||
|
self.doc.redo();
|
||||||
|
} else if pressed(Modifiers::COMMAND, Key::Z) {
|
||||||
|
self.doc.undo();
|
||||||
|
}
|
||||||
|
if pressed(Modifiers::COMMAND, Key::C) {
|
||||||
|
self.copy();
|
||||||
|
}
|
||||||
|
if pressed(Modifiers::NONE, Key::Escape) {
|
||||||
|
if self.doc.is_dirty() && !self.confirm_close {
|
||||||
|
self.confirm_close = true;
|
||||||
|
} else {
|
||||||
|
self.close(ctx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (key, tool) in [
|
||||||
|
(Key::A, Tool::Arrow),
|
||||||
|
(Key::R, Tool::Rectangle),
|
||||||
|
(Key::O, Tool::Ellipse),
|
||||||
|
(Key::L, Tool::Line),
|
||||||
|
(Key::P, Tool::Pen),
|
||||||
|
(Key::H, Tool::Highlighter),
|
||||||
|
] {
|
||||||
|
if pressed(Modifiers::NONE, key) {
|
||||||
|
self.tool = tool;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The compositor asked to close: with unsaved changes, show the bar instead.
|
||||||
|
fn guard_close(&mut self, ctx: &egui::Context) {
|
||||||
|
if ctx.input(|i| i.viewport().close_requested()) && self.doc.is_dirty() && !self.closing {
|
||||||
|
ctx.send_viewport_cmd(ViewportCommand::CancelClose);
|
||||||
|
self.confirm_close = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn toolbar(&mut self, ui: &mut egui::Ui) {
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
for tool in Tool::ALL {
|
||||||
|
if ui
|
||||||
|
.selectable_label(self.tool == tool, tool.label())
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
self.tool = tool;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ui.separator();
|
||||||
|
for (i, [r, g, b]) in PALETTE.into_iter().enumerate() {
|
||||||
|
let (rect, response) =
|
||||||
|
ui.allocate_exact_size(egui::vec2(18.0, 18.0), Sense::click());
|
||||||
|
ui.painter()
|
||||||
|
.rect_filled(rect, 4.0, Color32::from_rgb(r, g, b));
|
||||||
|
let ring = if i == self.color {
|
||||||
|
ui.visuals().strong_text_color()
|
||||||
|
} else {
|
||||||
|
ui.visuals().weak_text_color()
|
||||||
|
};
|
||||||
|
ui.painter().rect_stroke(
|
||||||
|
rect,
|
||||||
|
4.0,
|
||||||
|
Stroke::new(if i == self.color { 2.0 } else { 1.0 }, ring),
|
||||||
|
StrokeKind::Outside,
|
||||||
|
);
|
||||||
|
if response.clicked() {
|
||||||
|
self.color = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ui.separator();
|
||||||
|
for (i, (label, _)) in WIDTHS.into_iter().enumerate() {
|
||||||
|
if ui.selectable_label(self.width == i, label).clicked() {
|
||||||
|
self.width = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ui.separator();
|
||||||
|
if ui
|
||||||
|
.add_enabled(self.doc.can_undo(), egui::Button::new("Undo"))
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
self.doc.undo();
|
||||||
|
}
|
||||||
|
if ui
|
||||||
|
.add_enabled(self.doc.can_redo(), egui::Button::new("Redo"))
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
self.doc.redo();
|
||||||
|
}
|
||||||
|
ui.separator();
|
||||||
|
if ui.button("Copy").clicked() {
|
||||||
|
self.copy();
|
||||||
|
}
|
||||||
|
if ui.button("Save").clicked() {
|
||||||
|
self.save(false);
|
||||||
|
}
|
||||||
|
if let Some((text, at)) = &self.status
|
||||||
|
&& at.elapsed() < STATUS_FOR
|
||||||
|
{
|
||||||
|
ui.label(text.as_str());
|
||||||
|
ui.ctx().request_repaint_after(STATUS_FOR);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn canvas(&mut self, ui: &mut egui::Ui) {
|
||||||
|
let area = ui.max_rect();
|
||||||
|
let (response, painter) = ui.allocate_painter(area.size(), Sense::drag());
|
||||||
|
let size = self.base.dimensions();
|
||||||
|
let fit = Fit::new(
|
||||||
|
size,
|
||||||
|
(area.min.x, area.min.y, area.width(), area.height()),
|
||||||
|
ui.ctx().pixels_per_point(),
|
||||||
|
);
|
||||||
|
let to_pos = |p: shape::P| {
|
||||||
|
let (x, y) = fit.to_screen(p);
|
||||||
|
Pos2::new(x, y)
|
||||||
|
};
|
||||||
|
let image_rect =
|
||||||
|
Rect::from_min_max(to_pos((0.0, 0.0)), to_pos((size.0 as f32, size.1 as f32)));
|
||||||
|
painter.image(
|
||||||
|
self.texture.id(),
|
||||||
|
image_rect,
|
||||||
|
Rect::from_min_max(Pos2::ZERO, Pos2::new(1.0, 1.0)),
|
||||||
|
Color32::WHITE,
|
||||||
|
);
|
||||||
|
|
||||||
|
let shift = ui.input(|i| i.modifiers.shift);
|
||||||
|
let pointer = response
|
||||||
|
.interact_pointer_pos()
|
||||||
|
.map(|p| fit.to_image((p.x, p.y)));
|
||||||
|
if response.drag_started()
|
||||||
|
&& let Some(p) = pointer
|
||||||
|
&& image_rect.contains(response.interact_pointer_pos().unwrap_or_default())
|
||||||
|
{
|
||||||
|
self.drag = Some(Drag::new(self.tool, self.style(), p));
|
||||||
|
}
|
||||||
|
if let (Some(drag), Some(p)) = (&mut self.drag, pointer) {
|
||||||
|
drag.move_to(view::clamp(p, size));
|
||||||
|
}
|
||||||
|
if response.drag_stopped()
|
||||||
|
&& let Some(drag) = self.drag.take()
|
||||||
|
{
|
||||||
|
let shape = drag.shape(shift);
|
||||||
|
if !shape.is_click() {
|
||||||
|
self.doc.add(shape);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let live = self.drag.as_ref().map(|d| d.shape(shift));
|
||||||
|
for shape in self.doc.shapes().iter().chain(live.as_ref()) {
|
||||||
|
for prim in geometry(shape) {
|
||||||
|
paint(&painter, &prim, fit.scale, to_pos);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Draws one primitive on the canvas.
|
||||||
|
fn paint(painter: &egui::Painter, prim: &Prim, scale: f32, to_pos: impl Fn(shape::P) -> Pos2) {
|
||||||
|
let color = |c: &[u8; 4]| Color32::from_rgba_unmultiplied(c[0], c[1], c[2], c[3]);
|
||||||
|
match prim {
|
||||||
|
Prim::Fill { points, color: c } => {
|
||||||
|
let points = points.iter().map(|&p| to_pos(p)).collect();
|
||||||
|
painter.add(egui::Shape::convex_polygon(points, color(c), Stroke::NONE));
|
||||||
|
}
|
||||||
|
Prim::Stroke {
|
||||||
|
points,
|
||||||
|
width,
|
||||||
|
color: c,
|
||||||
|
closed,
|
||||||
|
round,
|
||||||
|
} => {
|
||||||
|
let points: Vec<Pos2> = points.iter().map(|&p| to_pos(p)).collect();
|
||||||
|
let stroke = Stroke::new(width * scale, color(c));
|
||||||
|
if *closed {
|
||||||
|
painter.add(egui::Shape::closed_line(points, stroke));
|
||||||
|
} else {
|
||||||
|
// egui lines have butt caps; add the round caps the export draws.
|
||||||
|
if *round {
|
||||||
|
for end in [points[0], points[points.len() - 1]] {
|
||||||
|
painter.circle_filled(end, width * scale / 2.0, color(c));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
painter.add(egui::Shape::line(points, stroke));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl eframe::App for Editor {
|
||||||
|
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
||||||
|
let ctx = ui.ctx().clone();
|
||||||
|
self.keys(&ctx);
|
||||||
|
self.guard_close(&ctx);
|
||||||
|
egui::Panel::top("toolbar").show(ui, |ui| self.toolbar(ui));
|
||||||
|
if self.confirm_close && self.doc.is_dirty() {
|
||||||
|
egui::Panel::bottom("unsaved").show(ui, |ui| {
|
||||||
|
ui.label("Unsaved changes — Esc to discard, Ctrl+S to save");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
egui::CentralPanel::no_frame().show(ui, |ui| self.canvas(ui));
|
||||||
|
}
|
||||||
|
}
|
||||||
+19
-3
@@ -1,6 +1,3 @@
|
|||||||
// The editor lands in pieces; Task 4 removes this.
|
|
||||||
#![allow(dead_code)]
|
|
||||||
|
|
||||||
mod capture;
|
mod capture;
|
||||||
mod config;
|
mod config;
|
||||||
mod detach;
|
mod detach;
|
||||||
@@ -72,11 +69,19 @@ enum Command {
|
|||||||
#[command(flatten)]
|
#[command(flatten)]
|
||||||
common: Common,
|
common: Common,
|
||||||
},
|
},
|
||||||
|
/// Mark up an image (opened by clicking a preview).
|
||||||
|
Edit {
|
||||||
|
/// The image to edit.
|
||||||
|
file: PathBuf,
|
||||||
|
},
|
||||||
/// Report what the compositor and system support.
|
/// Report what the compositor and system support.
|
||||||
Doctor,
|
Doctor,
|
||||||
/// Internal: the process that shows preview thumbnails.
|
/// Internal: the process that shows preview thumbnails.
|
||||||
#[command(name = "__preview-host", hide = true)]
|
#[command(name = "__preview-host", hide = true)]
|
||||||
PreviewHost,
|
PreviewHost,
|
||||||
|
/// Internal: serve a PNG from stdin as the clipboard (the editor's copy).
|
||||||
|
#[command(name = "__clipboard", hide = true)]
|
||||||
|
Clipboard,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Args)]
|
#[derive(Args)]
|
||||||
@@ -147,6 +152,13 @@ fn run(cli: Cli) -> Result<()> {
|
|||||||
Command::Region { common } => capture(Mode::Region, common),
|
Command::Region { common } => capture(Mode::Region, common),
|
||||||
Command::Window { common } => capture(Mode::Window, common),
|
Command::Window { common } => capture(Mode::Window, common),
|
||||||
Command::Zoom { common } => capture(Mode::Zoom, common),
|
Command::Zoom { common } => capture(Mode::Zoom, common),
|
||||||
|
Command::Edit { file } => editor::run(&file),
|
||||||
|
Command::Clipboard => {
|
||||||
|
let mut png = Vec::new();
|
||||||
|
std::io::Read::read_to_end(&mut std::io::stdin(), &mut png)
|
||||||
|
.context("could not read the image")?;
|
||||||
|
output::serve_clipboard(png)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -337,6 +349,9 @@ mod tests {
|
|||||||
assert!(!parses(&["window", "--clipboard-only", "-o", "a.png"]));
|
assert!(!parses(&["window", "--clipboard-only", "-o", "a.png"]));
|
||||||
assert!(parses(&["zoom", "--cursor", "--no-preview"]));
|
assert!(parses(&["zoom", "--cursor", "--no-preview"]));
|
||||||
assert!(!parses(&["zoom", "--clipboard-only", "-o", "a.png"]));
|
assert!(!parses(&["zoom", "--clipboard-only", "-o", "a.png"]));
|
||||||
|
assert!(parses(&["edit", "a.png"]));
|
||||||
|
assert!(!parses(&["edit"]));
|
||||||
|
assert!(parses(&["__clipboard"]));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -344,6 +359,7 @@ mod tests {
|
|||||||
assert!(parses(&["__preview-host"]));
|
assert!(parses(&["__preview-host"]));
|
||||||
let help = Cli::command().render_help().to_string();
|
let help = Cli::command().render_help().to_string();
|
||||||
assert!(!help.contains("preview-host"), "{help}");
|
assert!(!help.contains("preview-host"), "{help}");
|
||||||
|
assert!(!help.contains("__clipboard"), "{help}");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
+18
-6
@@ -6,7 +6,7 @@ use std::path::{Path, PathBuf};
|
|||||||
use anyhow::{Context, Result, anyhow};
|
use anyhow::{Context, Result, anyhow};
|
||||||
use chrono::{DateTime, Local};
|
use chrono::{DateTime, Local};
|
||||||
use image::{ImageFormat, RgbaImage};
|
use image::{ImageFormat, RgbaImage};
|
||||||
use wl_clipboard_rs::copy::{MimeType, Options, Source};
|
use wl_clipboard_rs::copy::{MimeType, Options, PreparedCopy, Source};
|
||||||
|
|
||||||
use crate::error::HintExt;
|
use crate::error::HintExt;
|
||||||
use crate::lock::Lock;
|
use crate::lock::Lock;
|
||||||
@@ -90,23 +90,35 @@ pub fn write_stdout(bytes: &[u8]) -> Result<()> {
|
|||||||
.context("could not write to stdout")
|
.context("could not write to stdout")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Copies `png` to the clipboard. The data is served by a forked child that
|
fn prepare_clipboard(png: Vec<u8>) -> Result<PreparedCopy> {
|
||||||
/// lives until something else is copied, so this consumes the lock.
|
|
||||||
pub fn copy_to_clipboard(png: Vec<u8>, lock: Lock) -> Result<()> {
|
|
||||||
let mut options = Options::new();
|
let mut options = Options::new();
|
||||||
options.foreground(true);
|
options.foreground(true);
|
||||||
let prepared = options
|
options
|
||||||
.prepare_copy(
|
.prepare_copy(
|
||||||
Source::Bytes(png.into()),
|
Source::Bytes(png.into()),
|
||||||
MimeType::Specific("image/png".into()),
|
MimeType::Specific("image/png".into()),
|
||||||
)
|
)
|
||||||
.context("could not copy to the clipboard")
|
.context("could not copy to the clipboard")
|
||||||
.hint("the compositor needs ext-data-control or wlr-data-control; see `valw doctor`")?;
|
.hint("the compositor needs ext-data-control or wlr-data-control; see `valw doctor`")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Copies `png` to the clipboard. The data is served by a forked child that
|
||||||
|
/// lives until something else is copied, so this consumes the lock.
|
||||||
|
pub fn copy_to_clipboard(png: Vec<u8>, lock: Lock) -> Result<()> {
|
||||||
|
let prepared = prepare_clipboard(png)?;
|
||||||
crate::detach::spawn(lock, move || {
|
crate::detach::spawn(lock, move || {
|
||||||
let _ = prepared.serve();
|
let _ = prepared.serve();
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Serves `png` as the clipboard from this process until something else
|
||||||
|
/// takes the clipboard.
|
||||||
|
pub fn serve_clipboard(png: Vec<u8>) -> Result<()> {
|
||||||
|
prepare_clipboard(png)?
|
||||||
|
.serve()
|
||||||
|
.context("the clipboard server failed")
|
||||||
|
}
|
||||||
|
|
||||||
/// Where a capture goes.
|
/// Where a capture goes.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub enum Target {
|
pub enum Target {
|
||||||
|
|||||||
Reference in new issue
Block a user