feat: valw edit
This commit is contained in:
1 parent
6ffb36d49f
commit
c28330fecb
7 files changed
+2595
-60
No files matched your search
@@ -4,3 +4,375 @@ pub mod doc;
|
||||
pub mod export;
|
||||
pub mod shape;
|
||||
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 config;
|
||||
mod detach;
|
||||
@@ -72,11 +69,19 @@ enum Command {
|
||||
#[command(flatten)]
|
||||
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.
|
||||
Doctor,
|
||||
/// Internal: the process that shows preview thumbnails.
|
||||
#[command(name = "__preview-host", hide = true)]
|
||||
PreviewHost,
|
||||
/// Internal: serve a PNG from stdin as the clipboard (the editor's copy).
|
||||
#[command(name = "__clipboard", hide = true)]
|
||||
Clipboard,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
@@ -147,6 +152,13 @@ fn run(cli: Cli) -> Result<()> {
|
||||
Command::Region { common } => capture(Mode::Region, common),
|
||||
Command::Window { common } => capture(Mode::Window, 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(&["zoom", "--cursor", "--no-preview"]));
|
||||
assert!(!parses(&["zoom", "--clipboard-only", "-o", "a.png"]));
|
||||
assert!(parses(&["edit", "a.png"]));
|
||||
assert!(!parses(&["edit"]));
|
||||
assert!(parses(&["__clipboard"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -344,6 +359,7 @@ mod tests {
|
||||
assert!(parses(&["__preview-host"]));
|
||||
let help = Cli::command().render_help().to_string();
|
||||
assert!(!help.contains("preview-host"), "{help}");
|
||||
assert!(!help.contains("__clipboard"), "{help}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+18
-6
@@ -6,7 +6,7 @@ use std::path::{Path, PathBuf};
|
||||
use anyhow::{Context, Result, anyhow};
|
||||
use chrono::{DateTime, Local};
|
||||
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::lock::Lock;
|
||||
@@ -90,23 +90,35 @@ pub fn write_stdout(bytes: &[u8]) -> Result<()> {
|
||||
.context("could not write to stdout")
|
||||
}
|
||||
|
||||
/// 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<()> {
|
||||
fn prepare_clipboard(png: Vec<u8>) -> Result<PreparedCopy> {
|
||||
let mut options = Options::new();
|
||||
options.foreground(true);
|
||||
let prepared = options
|
||||
options
|
||||
.prepare_copy(
|
||||
Source::Bytes(png.into()),
|
||||
MimeType::Specific("image/png".into()),
|
||||
)
|
||||
.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 || {
|
||||
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.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Target {
|
||||
|
||||
Reference in new issue
Block a user