klemmbrett 0.1.0

Minimalist, dumb and fast wayland native clipboard history manager
use std::error::Error;
use std::process::Command;
use std::sync::Arc;

use crate::proto::{self, Entry};
use crate::theme::Theme;

use eframe::egui::{
    self, Align, Color32, CornerRadius, CursorIcon, FontData, FontDefinitions, FontFamily, FontId, Frame, Key, Layout,
    Margin, Rect, RichText, Sense, Shadow, Stroke, TextStyle,
};

const WIDTH: f32 = 640.0;
const HEIGHT: f32 = 560.0;
const PAGE_SIZE: usize = 10;
const PREVIEW_LEN: usize = 120;
const THUMBNAIL_HEIGHT: f32 = 96.0;
const BORDER: f32 = 2.0;
const SHADOW: i8 = 4;
const STRIP: f32 = 6.0;

enum Preview {
    Text(String),
    Image(String),
    Binary,
}

struct Item {
    entry: Entry,
    preview: Preview,
}

struct Ui {
    theme: Theme,
    items: Vec<Item>,
    has_more: bool,
    selected: usize,
    scroll_to_selected: bool,
    error: Option<String>,
}

fn card<R>(ui: &mut egui::Ui, theme: &Theme, accent: Color32, fill: Color32, body: impl FnOnce(&mut egui::Ui) -> R) -> egui::Response {
    let res = Frame::new()
        .fill(fill)
        .stroke(Stroke::new(BORDER, theme.border))
        .inner_margin(Margin { left: 12 + STRIP as i8, right: 12, top: 8, bottom: 10 })
        .outer_margin(Margin { left: 0, right: SHADOW, top: 0, bottom: SHADOW })
        .shadow(Shadow { offset: [SHADOW, SHADOW], blur: 0, spread: 0, color: theme.border })
        .show(ui, |ui| {
            ui.set_width(ui.available_width());
            body(ui)
        })
        .response;
    let card = Rect::from_min_max(res.rect.min, res.rect.max - egui::Vec2::splat(SHADOW as f32));
    let strip = Rect::from_min_size(card.min + egui::Vec2::splat(BORDER), egui::vec2(STRIP, card.height() - 2.0 * BORDER));
    ui.painter().rect_filled(strip, CornerRadius::ZERO, accent);
    res
}

pub fn run() -> Result<(), Box<dyn Error>> {
    let options = eframe::NativeOptions {
        viewport: egui::ViewportBuilder::default()
            .with_app_id("klemmbrett")
            .with_title("klemmbrett")
            .with_inner_size([WIDTH, HEIGHT])
            .with_resizable(false),
        ..Default::default()
    };
    eframe::run_native(
        "klemmbrett",
        options,
        Box::new(|cc| {
            egui_extras::install_image_loaders(&cc.egui_ctx);
            let (theme, theme_error) = Theme::load();
            setup_style(&cc.egui_ctx, &theme);
            let mut ui = Ui { theme, items: Vec::new(), has_more: true, selected: 0, scroll_to_selected: false, error: None };
            ui.load_more(&cc.egui_ctx);
            ui.error = ui.error.or(theme_error);
            Ok(Box::new(ui))
        }),
    )?;
    Ok(())
}

fn system_font(pattern: &str) -> Option<FontData> {
    let out = Command::new("fc-match").args(["-f", "%{file}\n%{index}", pattern]).output().ok()?;
    let out = String::from_utf8(out.stdout).ok()?;
    let (file, index) = out.split_once('\n')?;
    let mut font = FontData::from_owned(std::fs::read(file).ok()?);
    font.index = index.trim().parse().unwrap_or(0);
    Some(font)
}

fn setup_style(ctx: &egui::Context, theme: &Theme) {
    ctx.set_theme(if theme.is_dark() { egui::Theme::Dark } else { egui::Theme::Light });

    let mut fonts = FontDefinitions::default();
    for (pattern, family) in [("sans-serif:bold", FontFamily::Proportional), ("monospace", FontFamily::Monospace)] {
        if let Some(font) = system_font(pattern) {
            fonts.font_data.insert(pattern.into(), Arc::new(font));
            fonts.families.entry(family).or_default().insert(0, pattern.into());
        }
    }
    ctx.set_fonts(fonts);

    ctx.all_styles_mut(|style| {
        style.text_styles = [
            (TextStyle::Small, FontId::proportional(14.0)),
            (TextStyle::Body, FontId::proportional(18.0)),
            (TextStyle::Button, FontId::proportional(18.0)),
            (TextStyle::Heading, FontId::proportional(28.0)),
            (TextStyle::Monospace, FontId::monospace(17.0)),
        ]
        .into();
        style.spacing.item_spacing = egui::vec2(10.0, 10.0);
    });
}

fn human_size(len: usize) -> String {
    match len {
        0..1024 => format!("{len} B"),
        1024..1048576 => format!("{:.1} KiB", len as f64 / 1024.0),
        _ => format!("{:.1} MiB", len as f64 / 1048576.0),
    }
}

impl Item {
    fn new(ctx: &egui::Context, entry: Entry) -> Self {
        let preview = if entry.mime.starts_with("text/") {
            let text = String::from_utf8_lossy(&entry.data);
            let line = text.lines().find(|l| !l.trim().is_empty()).unwrap_or_default().trim();
            let mut preview: String = line.chars().take(PREVIEW_LEN).collect();
            if line.chars().count() > PREVIEW_LEN || text.trim().lines().count() > 1 {
                preview.push('…');
            }
            Preview::Text(preview)
        } else if entry.mime.starts_with("image/") {
            let uri = format!("bytes://klemmbrett/{}", entry.id.iter().map(|b| format!("{b:02x}")).collect::<String>());
            ctx.include_bytes(uri.clone(), entry.data.clone());
            Preview::Image(uri)
        } else {
            Preview::Binary
        };
        Self { entry, preview }
    }
}

impl Ui {
    fn load_more(&mut self, ctx: &egui::Context) {
        match proto::list(PAGE_SIZE as u8 + 1, self.items.len() as u32) {
            Ok(mut entries) => {
                self.has_more = entries.len() > PAGE_SIZE;
                entries.truncate(PAGE_SIZE);
                self.items.extend(entries.into_iter().map(|e| Item::new(ctx, e)));
                self.error = None;
            }
            Err(e) => {
                self.has_more = false;
                self.error = Some(format!("failed to list entries: {e}"));
            }
        }
    }

    fn copy(&mut self, ctx: &egui::Context, idx: usize) {
        let Some(item) = self.items.get(idx) else { return };
        match proto::set(item.entry.id) {
            Ok(()) => ctx.send_viewport_cmd(egui::ViewportCommand::Close),
            Err(e) => self.error = Some(format!("failed to copy entry: {e}")),
        }
    }

    fn handle_keys(&mut self, ctx: &egui::Context) {
        const DIGITS: [Key; 10] =
            [Key::Num0, Key::Num1, Key::Num2, Key::Num3, Key::Num4, Key::Num5, Key::Num6, Key::Num7, Key::Num8, Key::Num9];
        let none = egui::Modifiers::NONE;
        let (down, up, enter, esc, digit) = ctx.input_mut(|i| {
            (
                i.consume_key(none, Key::ArrowDown) | i.consume_key(none, Key::J),
                i.consume_key(none, Key::ArrowUp) | i.consume_key(none, Key::K),
                i.consume_key(none, Key::Enter),
                i.consume_key(none, Key::Escape),
                DIGITS.iter().position(|k| i.consume_key(none, *k)),
            )
        });

        if esc {
            ctx.send_viewport_cmd(egui::ViewportCommand::Close);
        } else if let Some(idx) = digit {
            self.copy(ctx, idx);
        } else if enter {
            self.copy(ctx, self.selected);
        } else if down || up {
            if down && self.selected + 1 >= self.items.len() && self.has_more {
                self.load_more(ctx);
            }
            self.selected = if down { self.selected + 1 } else { self.selected.saturating_sub(1) };
            self.selected = self.selected.min(self.items.len().saturating_sub(1));
            self.scroll_to_selected = true;
        }
    }

    fn row(&self, ui: &mut egui::Ui, idx: usize) -> egui::Response {
        let t = &self.theme;
        let item = &self.items[idx];
        let selected = idx == self.selected;
        let text = if selected { t.on_accent } else { t.text };

        let muted = if selected { t.on_accent } else { t.muted };

        let body = |ui: &mut egui::Ui| {
            ui.spacing_mut().item_spacing.y = 4.0;
            let caption = format!("#{idx} · {} · {}", item.entry.mime, human_size(item.entry.data.len()));
            ui.add(egui::Label::new(RichText::new(caption).small().color(muted)).truncate());
            match &item.preview {
                Preview::Text(s) => {
                    ui.add(egui::Label::new(RichText::new(s).monospace().color(text)).truncate());
                }
                Preview::Image(uri) => {
                    ui.add(
                        egui::Image::new(uri.as_str())
                            .max_height(THUMBNAIL_HEIGHT)
                            .max_width(ui.available_width())
                            .corner_radius(CornerRadius::ZERO),
                    );
                }
                Preview::Binary => {
                    ui.label(RichText::new("binary data").italics().color(text));
                }
            }
        };

        let fill = if selected { t.selected } else { t.surface };
        let res = card(ui, t, t.accent(idx), fill, body);
        ui.interact(res.rect, ui.id().with(("row", idx)), Sense::click()).on_hover_cursor(CursorIcon::PointingHand)
    }
}

impl eframe::App for Ui {
    fn ui(&mut self, ui: &mut egui::Ui, _: &mut eframe::Frame) {
        let ctx = ui.ctx().clone();
        self.handle_keys(&ctx);

        let panel = Frame::new().fill(self.theme.background).inner_margin(16);
        egui::CentralPanel::default().frame(panel).show(ui, |ui| {
            let t = &self.theme;
            ui.horizontal(|ui| {
                ui.label(RichText::new("klemmbrett").heading().strong().color(t.text));
                ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
                    ui.label(RichText::new("↑↓ select · enter copy · 0-9 pick · esc close").small().color(t.muted));
                });
            });
            ui.add_space(SHADOW as f32);

            if let Some(error) = &self.error {
card(ui, t, t.error, t.surface, |ui| ui.label(RichText::new(error).color(t.text)));
            }
            if self.items.is_empty() && self.error.is_none() {
                ui.label(RichText::new("clipboard history is empty").color(t.muted));
            }

            let mut clicked = None;
            let mut hovered = None;
            let mut reached_end = false;
            egui::ScrollArea::vertical().auto_shrink(false).show(ui, |ui| {
                for idx in 0..self.items.len() {
                    let res = self.row(ui, idx);
                    if res.clicked() {
                        clicked = Some(idx);
                    }
                    if res.hovered() && ui.input(|i| i.pointer.is_moving()) {
                        hovered = Some(idx);
                    }
                    if idx == self.selected && self.scroll_to_selected {
                        res.scroll_to_me(None);
                    }
                }
                if self.has_more {
                    let res = ui.add(egui::Spinner::new().color(self.theme.text));
                    reached_end = ui.is_rect_visible(res.rect);
                }
            });
            self.scroll_to_selected = false;

            if let Some(idx) = hovered {
                self.selected = idx;
            }
            if let Some(idx) = clicked {
                self.copy(&ctx, idx);
            }
            if reached_end {
                self.load_more(&ctx);
            }
        });
    }
}