use std::path::PathBuf;
use std::sync::mpsc::Receiver;
use std::time::{Duration, Instant};
use anyhow::Result;
use eframe::{egui, App, CreationContext, Frame, NativeOptions};
use vernier_core::{
AppearanceSettings, ClipboardUnit, ColorRgba, CopyFormat, HandoffApp, IntegrationSettings,
RoundingMode, ScreenshotSettings, Settings, ShortcutSettings, ToleranceLevel,
ToleranceSettings,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Section {
General,
Screenshots,
Tolerance,
Appearance,
Integrations,
Shortcuts,
About,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ShortcutId {
Toggle,
ClearAndHide,
ClearAndExit,
Restore,
Capture,
Crosshair,
GuideHorizontal,
GuideVertical,
ColorToggle,
StuckHorizontal,
StuckVertical,
RefreshCapture,
ToleranceUp,
ToleranceDown,
NudgeLeft,
NudgeRight,
NudgeUp,
NudgeDown,
TakeNormalScreenshot,
}
impl Section {
fn label(self) -> &'static str {
match self {
Self::General => "General",
Self::Screenshots => "Screenshots",
Self::Tolerance => "Tolerance",
Self::Appearance => "Appearance",
Self::Integrations => "Integrations",
Self::Shortcuts => "Shortcuts",
Self::About => "About",
}
}
}
const SECTIONS: &[Section] = &[
Section::General,
Section::Screenshots,
Section::Tolerance,
Section::Appearance,
Section::Integrations,
Section::Shortcuts,
Section::About,
];
struct PrefsApp {
section: Section,
edited: Settings,
saved: Settings,
on_saved: Box<dyn FnMut() + Send>,
on_quit: Box<dyn FnMut() + Send>,
last_status: Option<String>,
logo: Option<egui::TextureHandle>,
handoff_icon: Option<(String, egui::TextureHandle)>,
installed_handoff_apps: Vec<(HandoffApp, Option<egui::TextureHandle>)>,
folder_pick: Option<Receiver<Option<PathBuf>>>,
handoff_pick: Option<Receiver<Option<PathBuf>>>,
capturing_shortcut: Option<ShortcutId>,
static_bind_warning: Option<PathBuf>,
daemon_alive: bool,
last_daemon_probe: Instant,
prefs_started_at: Instant,
my_build_id: String,
daemon_build_id: Option<String>,
screen_recording_ok: bool,
screen_recording_probe: Option<Receiver<bool>>,
last_recording_probe: Instant,
}
impl PrefsApp {
fn new(
cc: &CreationContext<'_>,
on_saved: Box<dyn FnMut() + Send>,
on_quit: Box<dyn FnMut() + Send>,
static_bind_warning: Option<PathBuf>,
) -> Self {
apply_style(&cc.egui_ctx);
{
let ctx = cc.egui_ctx.clone();
std::thread::spawn(move || loop {
std::thread::sleep(Duration::from_millis(500));
ctx.request_repaint();
});
}
let logo = load_logo_texture(&cc.egui_ctx);
let initial = Settings::load().unwrap_or_default();
let installed_handoff_apps: Vec<(HandoffApp, Option<egui::TextureHandle>)> =
vernier_core::find_installed_handoff_apps()
.into_iter()
.map(|app| {
let tex_name = format!("handoff_dropdown_{}", app.command);
let tex = load_handoff_icon_texture(
&cc.egui_ctx,
&tex_name,
&app.icon_path,
);
(app, tex)
})
.collect();
let now = Instant::now();
Self {
section: Section::General,
edited: initial.clone(),
saved: initial,
on_saved,
on_quit,
last_status: None,
logo,
handoff_icon: None,
installed_handoff_apps,
folder_pick: None,
handoff_pick: None,
capturing_shortcut: None,
static_bind_warning,
daemon_alive: true,
last_daemon_probe: now,
prefs_started_at: now,
my_build_id: vernier_core::build_id(),
daemon_build_id: None,
screen_recording_ok: true,
screen_recording_probe: Some(vernier_platform::probe_screen_recording()),
last_recording_probe: now,
}
}
fn dirty(&self) -> bool {
self.edited != self.saved
}
fn save_now(&mut self) {
match self.edited.save() {
Ok(_) => {
self.saved = self.edited.clone();
self.last_status = Some("Saved.".to_string());
(self.on_saved)();
}
Err(e) => {
self.last_status = Some(format!("Save failed: {e:#}"));
}
}
}
fn revert_now(&mut self) {
self.edited = self.saved.clone();
self.capturing_shortcut = None;
self.last_status = Some("Reverted to last save.".to_string());
}
}
impl App for PrefsApp {
fn update(&mut self, ctx: &egui::Context, _frame: &mut Frame) {
#[cfg(target_os = "macos")]
if self.capturing_shortcut.is_none() {
let close = ctx.input(|i| {
i.modifiers.mac_cmd
&& !i.modifiers.shift
&& !i.modifiers.alt
&& !i.modifiers.ctrl
&& i.key_pressed(egui::Key::W)
});
if close {
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
}
}
let probe_grace = Duration::from_secs(1);
let probe_interval = Duration::from_millis(750);
if self.prefs_started_at.elapsed() > probe_grace
&& self.last_daemon_probe.elapsed() > probe_interval
{
self.daemon_alive = is_daemon_responsive();
self.daemon_build_id = query_daemon_build_id();
self.last_daemon_probe = Instant::now();
}
ctx.request_repaint_after(probe_interval);
if let Some(rx) = &self.screen_recording_probe {
match rx.try_recv() {
Ok(authorized) => {
if authorized != self.screen_recording_ok {
log::info!(
"prefs: screen-recording authorized -> {authorized}"
);
}
self.screen_recording_ok = authorized;
self.screen_recording_probe = None;
}
Err(std::sync::mpsc::TryRecvError::Empty) => {}
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
self.screen_recording_probe = None;
}
}
}
if self.screen_recording_probe.is_none()
&& self.last_recording_probe.elapsed() > Duration::from_secs(3)
{
self.screen_recording_probe =
Some(vernier_platform::probe_screen_recording());
self.last_recording_probe = Instant::now();
}
if let Some(target) = self.capturing_shortcut {
let outcome = ctx.input_mut(|i| capture_outcome(i, target));
if let Some(outcome) = outcome {
match outcome {
CaptureOutcome::Cancel => self.capturing_shortcut = None,
CaptureOutcome::Commit(s) => {
match target {
ShortcutId::Toggle => self.edited.shortcuts.toggle = s,
ShortcutId::ClearAndHide => {
self.edited.shortcuts.clear_and_hide = s
}
ShortcutId::ClearAndExit => {
self.edited.shortcuts.clear_and_exit = s
}
ShortcutId::Restore => self.edited.shortcuts.restore_session = s,
ShortcutId::Capture => self.edited.shortcuts.capture = s,
ShortcutId::Crosshair => {
self.edited.shortcuts.crosshair_mode = s
}
ShortcutId::GuideHorizontal => {
self.edited.shortcuts.guide_horizontal = s
}
ShortcutId::GuideVertical => {
self.edited.shortcuts.guide_vertical = s
}
ShortcutId::ColorToggle => {
self.edited.shortcuts.color_toggle = s
}
ShortcutId::StuckHorizontal => {
self.edited.shortcuts.stuck_horizontal = s
}
ShortcutId::StuckVertical => {
self.edited.shortcuts.stuck_vertical = s
}
ShortcutId::RefreshCapture => {
self.edited.shortcuts.refresh_capture = s
}
ShortcutId::ToleranceUp => {
self.edited.shortcuts.tolerance_up = s
}
ShortcutId::ToleranceDown => {
self.edited.shortcuts.tolerance_down = s
}
ShortcutId::NudgeLeft => self.edited.shortcuts.nudge_left = s,
ShortcutId::NudgeRight => self.edited.shortcuts.nudge_right = s,
ShortcutId::NudgeUp => self.edited.shortcuts.nudge_up = s,
ShortcutId::NudgeDown => self.edited.shortcuts.nudge_down = s,
ShortcutId::TakeNormalScreenshot => {
self.edited.shortcuts.take_normal_screenshot = s
}
}
self.capturing_shortcut = None;
}
}
}
}
if let Some(rx) = self.folder_pick.as_ref() {
match rx.try_recv() {
Ok(Some(path)) => {
self.edited.screenshots.output_dir = Some(path);
self.folder_pick = None;
}
Ok(None) => {
self.folder_pick = None;
}
Err(std::sync::mpsc::TryRecvError::Empty) => {}
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
self.folder_pick = None;
}
}
}
if let Some(rx) = self.handoff_pick.as_ref() {
match rx.try_recv() {
Ok(Some(path)) => {
let info = vernier_core::lookup_for_binary(&path).unwrap_or_else(|| {
let basename = path
.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| path.to_string_lossy().into_owned());
HandoffApp {
name: basename,
command: path.to_string_lossy().into_owned(),
args: "{file}".to_string(),
icon_path: String::new(),
}
});
apply_handoff_app(&mut self.edited.screenshots, info);
self.edited.screenshots.handoff_enabled = true;
self.handoff_pick = None;
}
Ok(None) => {
self.handoff_pick = None;
}
Err(std::sync::mpsc::TryRecvError::Empty) => {}
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
self.handoff_pick = None;
}
}
}
let icon_path = self.edited.screenshots.handoff_icon_path.clone();
let needs_reload = match &self.handoff_icon {
Some((cached, _)) => cached.as_str() != icon_path,
None => !icon_path.is_empty(),
};
if needs_reload {
self.handoff_icon = if icon_path.is_empty() {
None
} else {
load_handoff_icon_texture(ctx, "handoff_card_icon", &icon_path)
.map(|tex| (icon_path.clone(), tex))
};
}
egui::SidePanel::left("prefs_sidebar")
.resizable(false)
.default_width(200.0)
.show(ctx, |ui| {
ui.add_space(16.0);
ui.horizontal(|ui| {
ui.add_space(4.0);
if let Some(logo) = &self.logo {
ui.add(
egui::Image::new(logo)
.fit_to_exact_size(egui::vec2(28.0, 28.0)),
);
ui.add_space(8.0);
}
ui.heading("Vernier");
});
ui.add_space(14.0);
ui.separator();
ui.add_space(8.0);
for section in SECTIONS {
let selected = self.section == *section;
if sidebar_item(ui, selected, section.label()).clicked() {
self.section = *section;
}
}
});
let mut quit_requested = false;
egui::TopBottomPanel::bottom("prefs_actions")
.min_height(54.0)
.show(ctx, |ui| {
ui.horizontal_centered(|ui| {
ui.add_space(4.0);
let quit_label = egui::RichText::new("Quit Vernier")
.color(egui::Color32::from_rgb(220, 90, 90));
if ui.add(egui::Button::new(quit_label)).clicked() {
quit_requested = true;
}
if let Some(daemon_id) = self.daemon_build_id.as_ref() {
if daemon_id != &self.my_build_id {
ui.add_space(8.0);
let label = egui::RichText::new("Relaunch daemon (new build)")
.color(egui::Color32::from_rgb(220, 160, 50));
let resp = ui.add(egui::Button::new(label)).on_hover_text(format!(
"Daemon is running build {daemon_id}; prefs is on {}. \
Click to quit the old daemon and spawn the new one.",
self.my_build_id
));
if resp.clicked() {
relaunch_daemon_now();
self.last_daemon_probe =
Instant::now() - Duration::from_secs(60);
}
}
}
ui.add_space(12.0);
let dirty = self.dirty();
if !dirty {
if let Some(msg) = &self.last_status {
ui.label(egui::RichText::new(msg).color(egui::Color32::from_gray(180)));
}
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.add_space(4.0);
let revertable = dirty || self.capturing_shortcut.is_some();
if ui.add_enabled(dirty, egui::Button::new("Save")).clicked() {
self.save_now();
}
ui.add_space(4.0);
if ui.add_enabled(revertable, egui::Button::new("Revert")).clicked() {
self.revert_now();
}
if dirty {
ui.add_space(8.0);
let dot_size = egui::vec2(8.0, 8.0);
let (rect, _) =
ui.allocate_exact_size(dot_size, egui::Sense::hover());
ui.painter().circle_filled(
rect.center(),
4.0,
egui::Color32::from_rgb(220, 160, 50),
);
ui.add_space(2.0);
ui.colored_label(
egui::Color32::from_rgb(220, 160, 50),
"Unsaved changes",
);
}
});
});
});
if quit_requested {
(self.on_quit)();
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
}
if !self.screen_recording_ok {
egui::TopBottomPanel::top("screen_recording_banner")
.frame(
egui::Frame::NONE
.fill(egui::Color32::from_rgb(60, 32, 32))
.stroke(egui::Stroke::new(
1.0,
egui::Color32::from_rgb(170, 80, 70),
))
.inner_margin(egui::Margin::symmetric(20, 12)),
)
.show(ctx, |ui| {
ui.label(
egui::RichText::new("⚠ Screen Recording is off")
.color(egui::Color32::from_rgb(255, 140, 120))
.size(13.5)
.strong(),
);
ui.add_space(3.0);
ui.label(
egui::RichText::new(
"Vernier needs the Screen Recording permission to detect \
edges, freeze the screen, and take screenshots. Until it's \
granted, measure mode tracks the cursor but can't snap to \
pixels.",
)
.color(egui::Color32::from_gray(210))
.size(12.0),
);
ui.add_space(8.0);
if ui
.add(egui::Button::new(
egui::RichText::new("Open System Settings").size(12.5),
))
.clicked()
{
vernier_platform::open_screen_recording_settings();
}
ui.add_space(6.0);
ui.label(
egui::RichText::new(
"Enable Vernier under Screen & System Audio Recording, \
then quit and reopen Vernier.",
)
.color(egui::Color32::from_gray(150))
.size(11.5),
);
});
}
egui::CentralPanel::default()
.frame(egui::Frame::central_panel(&ctx.style()).inner_margin(egui::Margin::symmetric(20, 18)))
.show(ctx, |ui| {
if !matches!(self.section, Section::About) {
ui.heading(self.section.label());
ui.add_space(14.0);
}
egui::ScrollArea::vertical()
.auto_shrink([false, false])
.show(ui, |ui| match self.section {
Section::General => general_section(ui, &mut self.edited),
Section::Screenshots => screenshots_section(
ui,
&mut self.edited.screenshots,
&mut self.folder_pick,
&mut self.handoff_pick,
self.handoff_icon.as_ref().map(|(_, tex)| tex),
&self.installed_handoff_apps,
),
Section::Tolerance => tolerance_section(ui, &mut self.edited.tolerance),
Section::Appearance => {
appearance_section(ui, &mut self.edited.appearance)
}
Section::Integrations => {
integrations_section(ui, &mut self.edited.integrations)
}
Section::Shortcuts => shortcuts_section(
ui,
&mut self.edited.shortcuts,
&mut self.capturing_shortcut,
self.static_bind_warning.as_deref(),
),
Section::About => about_section(ui, self.logo.as_ref()),
});
});
if !self.daemon_alive && self.prefs_started_at.elapsed() > probe_grace {
paint_daemon_dead_modal(ctx, &mut self.last_daemon_probe);
}
}
}
fn is_daemon_responsive() -> bool {
let path = daemon_socket_path();
if !path.exists() {
return false;
}
std::os::unix::net::UnixStream::connect(&path).is_ok()
}
fn query_daemon_build_id() -> Option<String> {
use std::io::{BufRead, BufReader, Write};
use std::time::Duration;
let path = daemon_socket_path();
if !path.exists() {
return None;
}
let mut stream = std::os::unix::net::UnixStream::connect(&path).ok()?;
let _ = stream.set_read_timeout(Some(Duration::from_millis(500)));
let _ = stream.set_write_timeout(Some(Duration::from_millis(500)));
stream.write_all(b"version\n").ok()?;
let _ = stream.flush();
let _ = stream.shutdown(std::net::Shutdown::Write);
let reader = BufReader::new(stream);
let line = reader.lines().next()?.ok()?;
let trimmed = line.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
}
fn relaunch_daemon_now() {
use std::io::Write;
use std::time::Duration;
let path = daemon_socket_path();
if path.exists() {
if let Ok(mut stream) = std::os::unix::net::UnixStream::connect(&path) {
let _ = stream.write_all(b"quit\n");
let _ = stream.flush();
}
let deadline = Instant::now() + Duration::from_secs(1);
while Instant::now() < deadline {
if !path.exists() {
break;
}
std::thread::sleep(Duration::from_millis(50));
}
}
if let Ok(exe) = std::env::current_exe() {
match std::process::Command::new(&exe).spawn() {
Ok(c) => log::info!("daemon relaunched from prefs (pid {})", c.id()),
Err(e) => log::warn!("relaunch daemon spawn failed: {e:#}"),
}
}
}
fn daemon_socket_path() -> PathBuf {
let runtime_dir = std::env::var_os("XDG_RUNTIME_DIR")
.map(PathBuf::from)
.unwrap_or_else(std::env::temp_dir);
runtime_dir.join("vernier.sock")
}
fn paint_daemon_dead_modal(ctx: &egui::Context, last_probe: &mut Instant) {
let dim = ctx.layer_painter(egui::LayerId::new(
egui::Order::Middle,
egui::Id::new("vernier_daemon_dim"),
));
dim.rect_filled(
ctx.screen_rect(),
egui::CornerRadius::ZERO,
egui::Color32::from_black_alpha(160),
);
let mut relaunch_clicked = false;
egui::Area::new(egui::Id::new("vernier_daemon_modal"))
.order(egui::Order::Foreground)
.anchor(egui::Align2::CENTER_CENTER, egui::vec2(0.0, 0.0))
.interactable(true)
.show(ctx, |ui| {
egui::Frame::group(ui.style())
.fill(egui::Color32::from_gray(34))
.stroke(egui::Stroke::new(1.0, egui::Color32::from_gray(70)))
.corner_radius(egui::CornerRadius::same(10))
.inner_margin(egui::Margin::symmetric(24, 22))
.show(ui, |ui| {
ui.set_max_width(360.0);
ui.vertical_centered(|ui| {
ui.label(
egui::RichText::new("Vernier daemon stopped")
.size(16.0)
.strong(),
);
ui.add_space(10.0);
ui.label(
egui::RichText::new(
"The background daemon isn't responding. \
Shortcuts, the tray icon, and the toggle \
hotkey will stay inactive until it's \
running again.",
)
.color(egui::Color32::from_gray(200)),
);
ui.add_space(16.0);
if ui
.add(
egui::Button::new(
egui::RichText::new("Relaunch daemon")
.size(14.0)
.color(egui::Color32::from_rgb(140, 200, 255)),
)
.min_size(egui::vec2(160.0, 32.0)),
)
.clicked()
{
relaunch_clicked = true;
}
});
});
});
if relaunch_clicked {
if let Ok(exe) = std::env::current_exe() {
match std::process::Command::new(&exe).spawn() {
Ok(c) => log::info!(
"daemon relaunched from prefs modal (pid {})",
c.id()
),
Err(e) => log::warn!("relaunch from prefs modal failed: {e:#}"),
}
}
*last_probe = Instant::now() - Duration::from_secs(60);
}
}
fn apply_style(ctx: &egui::Context) {
use egui::FontFamily::Proportional;
use egui::TextStyle::*;
install_glyph_fonts(ctx);
ctx.style_mut(|style| {
style.text_styles = [
(Heading, egui::FontId::new(21.0, Proportional)),
(Body, egui::FontId::new(14.0, Proportional)),
(Monospace, egui::FontId::new(13.0, egui::FontFamily::Monospace)),
(Button, egui::FontId::new(14.0, Proportional)),
(Small, egui::FontId::new(12.0, Proportional)),
]
.into();
style.spacing.item_spacing = egui::vec2(8.0, 8.0);
style.spacing.button_padding = egui::vec2(12.0, 6.0);
style.spacing.indent = 14.0;
style.spacing.interact_size = egui::vec2(40.0, 28.0);
style.spacing.icon_width = 18.0;
style.spacing.icon_spacing = 6.0;
style.visuals.widgets.inactive.expansion = 0.0;
});
}
static OMARCHY_FONT_AVAILABLE: std::sync::atomic::AtomicBool =
std::sync::atomic::AtomicBool::new(false);
fn omarchy_font_available() -> bool {
OMARCHY_FONT_AVAILABLE.load(std::sync::atomic::Ordering::Relaxed)
}
fn install_glyph_fonts(ctx: &egui::Context) {
let mut fonts = egui::FontDefinitions::default();
let mut shortcut_chain: Vec<String> = Vec::new();
let letter_paths = [
"/usr/share/fonts/liberation/LiberationSans-Bold.ttf",
"/usr/share/fonts/liberation/LiberationMono-Bold.ttf",
"/usr/share/fonts/TTF/JetBrainsMonoNerdFont-Bold.ttf",
"/System/Library/Fonts/SFNS.ttf",
"/System/Library/Fonts/Helvetica.ttc",
"/System/Library/Fonts/HelveticaNeue.ttc",
"/Library/Fonts/Arial Bold.ttf",
];
for path in letter_paths {
match std::fs::read(path) {
Ok(bytes) => {
fonts.font_data.insert(
"shortcut_letters".into(),
std::sync::Arc::new(egui::FontData::from_owned(bytes)),
);
shortcut_chain.push("shortcut_letters".to_string());
break;
}
Err(_) => continue,
}
}
let omarchy_path = std::env::var_os("HOME").map(|h| {
std::path::PathBuf::from(h).join(".local/share/fonts/omarchy.ttf")
});
if let Some(path) = omarchy_path {
match std::fs::read(&path) {
Ok(bytes) => {
let mut data = egui::FontData::from_owned(bytes);
data.tweak = egui::FontTweak {
scale: 0.85,
y_offset_factor: 0.10,
..Default::default()
};
fonts
.font_data
.insert("omarchy".into(), std::sync::Arc::new(data));
shortcut_chain.push("omarchy".to_string());
OMARCHY_FONT_AVAILABLE
.store(true, std::sync::atomic::Ordering::Relaxed);
}
Err(e) => {
log::debug!("omarchy font not loaded ({}): {e}", path.display());
}
}
}
if let Some(default_prop) = fonts
.families
.get(&egui::FontFamily::Proportional)
.cloned()
{
shortcut_chain.extend(default_prop);
}
fonts
.families
.insert(egui::FontFamily::Name("shortcut".into()), shortcut_chain);
ctx.set_fonts(fonts);
}
fn sidebar_item(ui: &mut egui::Ui, selected: bool, label: &str) -> egui::Response {
let height = 32.0;
let response = ui.allocate_response(
egui::vec2(ui.available_width(), height),
egui::Sense::click(),
);
let visuals = ui.style().interact_selectable(&response, selected);
if selected || response.hovered() {
ui.painter().rect_filled(
response.rect.expand(-2.0),
egui::CornerRadius::same(6),
visuals.bg_fill,
);
}
let text_pos = response.rect.left_center() + egui::vec2(12.0, 0.0);
ui.painter().text(
text_pos,
egui::Align2::LEFT_CENTER,
label,
egui::FontId::proportional(14.0),
visuals.text_color(),
);
response
}
fn load_logo_texture(ctx: &egui::Context) -> Option<egui::TextureHandle> {
let size = 256;
let rgba = vernier_platform::render_app_icon_rgba(size);
if rgba.len() != (size as usize) * (size as usize) * 4 {
return None;
}
let image = egui::ColorImage::from_rgba_unmultiplied([size as usize, size as usize], &rgba);
Some(ctx.load_texture("vernier_logo", image, egui::TextureOptions::LINEAR))
}
fn load_handoff_icon_texture(
ctx: &egui::Context,
name: &str,
path: &str,
) -> Option<egui::TextureHandle> {
if path.is_empty() {
return None;
}
let lower = path.to_ascii_lowercase();
let size = 128u32;
let rgba: Vec<u8> = if lower.ends_with(".app") {
#[cfg(target_os = "macos")]
{
vernier_platform::extract_macos_app_icon_rgba(std::path::Path::new(path), size)?
}
#[cfg(not(target_os = "macos"))]
{
return None;
}
} else if lower.ends_with(".svg") {
let bytes = std::fs::read(path).ok()?;
vernier_platform::rasterize_svg(&bytes, size)?
} else if lower.ends_with(".png") {
let bytes = std::fs::read(path).ok()?;
vernier_platform::rasterize_png(&bytes, size)?
} else {
return None;
};
if rgba.len() != (size as usize) * (size as usize) * 4 {
return None;
}
let image = egui::ColorImage::from_rgba_unmultiplied([size as usize, size as usize], &rgba);
Some(ctx.load_texture(name, image, egui::TextureOptions::LINEAR))
}
fn apply_handoff_app(s: &mut ScreenshotSettings, app: HandoffApp) {
s.handoff_command = app.command;
s.handoff_app_name = app.name;
s.handoff_args = app.args;
s.handoff_icon_path = app.icon_path;
}
fn general_section(ui: &mut egui::Ui, settings: &mut Settings) {
let refresh_key = settings.shortcuts.refresh_capture.clone();
let s = &mut settings.general;
setting(ui, |ui| {
ui.checkbox(&mut s.launch_at_login, "Launch at login");
caption(ui,
"Adds an autostart entry. Uncheck to remove it on save.",
);
});
setting(ui, |ui| {
let mut show_tray = !s.hide_tray_icon;
if ui.checkbox(&mut show_tray, "Show tray icon").changed() {
s.hide_tray_icon = !show_tray;
}
caption(ui,
"Off keeps the daemon running but hides the tray menu. Drive it via the global hotkey or `vernier toggle`.",
);
});
ui.separator();
ui.add_space(10.0);
setting(ui, |ui| {
field_label(ui, "Clipboard format");
for fmt in [
CopyFormat::WidthCommaHeight,
CopyFormat::HeightCommaWidth,
CopyFormat::CssWidthFirst,
CopyFormat::CssHeightFirst,
CopyFormat::SassWidthFirst,
CopyFormat::SassHeightFirst,
] {
ui.radio_value(&mut s.copy_dimensions_format, fmt, fmt.label());
}
ui.add_space(6.0);
ui.horizontal(|ui| {
ui.label("Unit:");
ui.radio_value(&mut s.copy_dimensions_unit, ClipboardUnit::Px, "px");
ui.radio_value(&mut s.copy_dimensions_unit, ClipboardUnit::Rem, "rem");
});
ui.add_enabled_ui(s.copy_dimensions_unit == ClipboardUnit::Rem, |ui| {
ui.horizontal(|ui| {
ui.label("Base font size:");
let mut base = s.copy_dimensions_rem_base as i32;
if ui
.add(egui::DragValue::new(&mut base).range(1..=200).suffix(" px"))
.changed()
{
s.copy_dimensions_rem_base = base.max(1) as u32;
}
});
});
ui.checkbox(
&mut s.copy_dimensions_linebreak,
"Line break between width and height",
);
caption(ui,
"Format used when the copy-dimensions shortcut puts a held rectangle's width and height on the clipboard. The unit applies to the CSS and SASS formats — rem divides by the base font size.",
);
});
setting(ui, |ui| {
field_label(ui, "Units");
ui.radio_value(
&mut s.rounding_mode,
RoundingMode::Points,
"Points (logical, fractional)",
);
ui.radio_value(
&mut s.rounding_mode,
RoundingMode::PointsRounded,
"Points (rounded to integer)",
);
ui.radio_value(
&mut s.rounding_mode,
RoundingMode::ScreenPixels,
"Screen pixels (multiplied by display scale)",
);
ui.checkbox(&mut s.display_units, "Display units");
ui.checkbox(&mut s.display_wh_indicators, "Display width/height indicators");
caption(ui,
"How dimensions are reported on scaled displays. Screen pixels is the exact device-pixel count, identical to logical pixels on 1\u{00D7} displays; the Points modes divide by the display scale. Display units appends a `px` suffix; W/H indicators prefix area pills with `W:` / `H:`.",
);
});
setting(ui, |ui| {
field_label(ui, "Cursor");
ui.checkbox(&mut s.show_cursor, "Show");
caption(ui, &format!(
"Toggle the white-outlined `+` over the cursor. Off hides only the `+`, \
leaving the axis lines, ticks, and W\u{00D7}H pill rendering. \
Hold {} to hide the cursor momentarily for a clean read.",
alt_key_label(),
));
});
setting(ui, |ui| {
field_label(ui, "Aspect ratio");
ui.radio_value(
&mut s.aspect_mode,
vernier_core::AspectMode::Automatic,
"Automatic (common ratio when close, otherwise reduced)",
);
ui.radio_value(
&mut s.aspect_mode,
vernier_core::AspectMode::CommonOnly,
"Only common values (hide when nothing matches)",
);
ui.radio_value(
&mut s.aspect_mode,
vernier_core::AspectMode::Standard,
"Always pick the closest common value",
);
ui.radio_value(
&mut s.aspect_mode,
vernier_core::AspectMode::Reduced,
"Always show the reduced fraction",
);
ui.checkbox(&mut s.aspect_in_distance_tool, "Enable in distance tool");
ui.checkbox(&mut s.aspect_in_area_tool, "Enable in area tool");
});
setting(ui, |ui| {
field_label(ui, "Distance tool");
ui.checkbox(&mut s.snap_to_guides, "Snap to guides");
ui.checkbox(&mut s.snap_to_objects, "Snap to selected objects");
caption(ui, &format!(
"Snap to guides magnetizes drag endpoints to the nearest reference guide \
(30 px on the initial click, 8 px during and at the end of the drag). \
Snap to selected objects stops the live measurement on the edges of held \
rectangles. Hold {} to measure freeform.",
alt_key_label(),
));
});
setting(ui, |ui| {
if cfg!(target_os = "linux") {
s.freeze_screen = true;
ui.add_enabled(
false,
egui::Checkbox::new(&mut s.freeze_screen, "Freeze screen"),
);
caption(ui, &format!(
"Required on Wayland: the compositor's screencast captures Vernier's own \
overlay along with the screen, so live measurements were inaccurate. Live \
mode needs upstream support for excluding overlay layers from the capture. \
Press `{refresh_key}` while measuring to refresh the frozen frame.",
));
} else {
ui.checkbox(&mut s.freeze_screen, "Freeze screen");
caption(ui, &format!(
"On (default): the captured frame is locked when measure mode opens; press `{refresh_key}` to refresh manually. \
Off: edge detection follows live screen content as the cursor moves.",
));
}
});
}
fn screenshots_section(
ui: &mut egui::Ui,
s: &mut ScreenshotSettings,
folder_pick: &mut Option<Receiver<Option<PathBuf>>>,
handoff_pick: &mut Option<Receiver<Option<PathBuf>>>,
handoff_icon: Option<&egui::TextureHandle>,
installed_apps: &[(HandoffApp, Option<egui::TextureHandle>)],
) {
paint_handoff_card(ui, s, handoff_icon, handoff_pick, installed_apps);
ui.add_space(18.0);
setting(ui, |ui| {
ui.horizontal(|ui| {
field_label(ui, "Context margin");
let mut pad = s.padding_px as i32;
if ui
.add(
egui::DragValue::new(&mut pad)
.range(0..=64)
.suffix(" px"),
)
.changed()
{
s.padding_px = pad.max(0) as u32;
}
});
caption(ui,
"Pixels of extra screen content captured outside the measured region — \
useful for annotation context, since the W×H is already in the saved image.",
);
});
setting(ui, |ui| {
ui.checkbox(&mut s.retina_downscale, "Retina downscale");
caption(ui,
"Save the captured region at logical (point) pixels rather than the raw HiDPI buffer.",
);
});
setting(ui, |ui| {
ui.checkbox(&mut s.capture_sound, "Play shutter sound");
caption(ui,
"Plays the system screen-capture sound when a screenshot fires.",
);
});
setting(ui, |ui| {
field_label(ui, "Take normal screenshot (right-click menu)");
padded_text_edit(ui, &mut s.external_screenshot_command);
caption(ui,
"Shell command for \"Take Normal Screenshot\" (right-click menu / `CTRL+S`). \
Vernier exits measure mode first, then spawns this via `sh -c` so pipelines work, \
e.g. `grim -g \"$(slurp)\" - | wl-copy`. Distinct from the handoff app above, \
which routes Vernier's own region captures.",
);
});
ui.separator();
ui.add_space(8.0);
let detail_enabled = !s.handoff_enabled;
field_label(ui, "Vernier-managed save");
if detail_enabled {
caption(ui,
"Where Vernier writes the captured PNG, the filename template, \
and post-capture clipboard / notification behavior. Active \
because handoff is off.",
);
} else {
caption(ui, &format!(
"Disabled because handoff is on — {} owns where the screenshot \
goes, its filename, and any clipboard / edit-action behavior. \
Turn the Enable checkbox above off to manage these here.",
handoff_label_for(s),
));
}
ui.add_space(8.0);
ui.add_enabled_ui(detail_enabled, |ui| {
setting(ui, |ui| {
field_label(ui, "Output directory");
ui.horizontal(|ui| {
let mut dir_str = s
.output_dir
.as_ref()
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_default();
let resp = ui.add(
egui::TextEdit::singleline(&mut dir_str)
.margin(egui::Margin::symmetric(8, 6))
.desired_width(ui.available_width() - 96.0),
);
if resp.changed() {
s.output_dir = if dir_str.trim().is_empty() {
None
} else {
Some(PathBuf::from(dir_str.trim()))
};
}
let browse_enabled = folder_pick.is_none();
if ui
.add_enabled(browse_enabled, egui::Button::new("Browse…"))
.clicked()
{
let starting = s.output_dir.clone();
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let mut dialog = rfd::FileDialog::new().set_title("Output directory");
if let Some(d) = starting.as_ref().filter(|p| p.exists()) {
dialog = dialog.set_directory(d);
}
let _ = tx.send(dialog.pick_folder());
});
*folder_pick = Some(rx);
}
});
caption(ui,
"Empty = $XDG_PICTURES_DIR (or ~/Pictures). Non-existent paths are created on capture.",
);
});
setting(ui, |ui| {
field_label(ui, "Filename template");
padded_text_edit(ui, &mut s.filename_template);
caption(ui, "Tokens: {ts} timestamp, {w} width, {h} height.");
});
setting(ui, |ui| {
ui.checkbox(&mut s.copy_to_clipboard, "Copy image to clipboard");
let has_pick = !s.handoff_command.is_empty();
let app_name = handoff_label_for(s);
let label = if has_pick {
format!("Show \"Edit\" action in notification (opens in {app_name})")
} else {
"Show \"Edit\" action in notification (pick a handoff app first)"
.to_string()
};
ui.add_enabled_ui(has_pick, |ui| {
ui.checkbox(&mut s.handoff_edit_action, label);
});
});
});
}
fn handoff_label_for(s: &ScreenshotSettings) -> String {
if !s.handoff_app_name.is_empty() {
return s.handoff_app_name.clone();
}
if !s.handoff_command.is_empty() {
return PathBuf::from(&s.handoff_command)
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| s.handoff_command.clone());
}
"the handoff app".to_string()
}
fn paint_handoff_card(
ui: &mut egui::Ui,
s: &mut ScreenshotSettings,
icon: Option<&egui::TextureHandle>,
handoff_pick: &mut Option<Receiver<Option<PathBuf>>>,
installed_apps: &[(HandoffApp, Option<egui::TextureHandle>)],
) {
let has_pick = !s.handoff_command.is_empty();
let app_name = if has_pick {
handoff_label_for(s)
} else {
String::new()
};
egui::Frame::group(ui.style())
.fill(egui::Color32::from_gray(34))
.stroke(egui::Stroke::new(1.0, egui::Color32::from_gray(60)))
.corner_radius(egui::CornerRadius::same(10))
.inner_margin(egui::Margin::symmetric(18, 16))
.show(ui, |ui| {
ui.horizontal(|ui| {
if let Some(tex) = icon {
ui.add(
egui::Image::new(tex).fit_to_exact_size(egui::vec2(72.0, 72.0)),
);
ui.add_space(14.0);
} else {
let (rect, _) = ui.allocate_exact_size(
egui::vec2(72.0, 72.0),
egui::Sense::hover(),
);
ui.painter().rect_filled(
rect,
egui::CornerRadius::same(14),
egui::Color32::from_gray(50),
);
ui.add_space(14.0);
}
ui.vertical(|ui| {
ui.label(
egui::RichText::new("Screenshot handoff")
.size(16.0)
.strong(),
);
ui.add_space(4.0);
ui.horizontal(|ui| {
if has_pick {
ui.label(
egui::RichText::new(format!("App: {app_name}"))
.color(egui::Color32::from_gray(220))
.size(13.0),
);
} else {
ui.label(
egui::RichText::new("No app selected")
.color(egui::Color32::from_gray(190))
.italics()
.size(13.0),
);
}
ui.add_space(8.0);
paint_handoff_dropdown(ui, s, installed_apps);
ui.add_space(4.0);
let pick_enabled = handoff_pick.is_none();
if ui
.add_enabled(pick_enabled, egui::Button::new("Browse…"))
.on_hover_text(
"Pick a binary that isn't in the dropdown list",
)
.clicked()
{
let starting = if has_pick {
PathBuf::from(&s.handoff_command)
.parent()
.map(|p| p.to_path_buf())
} else {
None
};
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let mut dialog = rfd::FileDialog::new()
.set_title("Choose screenshot app");
let start = starting
.filter(|p| p.exists())
.unwrap_or_else(|| PathBuf::from("/usr/bin"));
if start.exists() {
dialog = dialog.set_directory(start);
}
let _ = tx.send(dialog.pick_file());
});
*handoff_pick = Some(rx);
}
if has_pick
&& ui
.button("Remove")
.on_hover_text("Clear the picked app and turn handoff off")
.clicked()
{
s.handoff_command.clear();
s.handoff_app_name.clear();
s.handoff_args.clear();
s.handoff_icon_path.clear();
s.handoff_enabled = false;
}
});
if has_pick && !s.handoff_command.is_empty() {
ui.label(
egui::RichText::new(&s.handoff_command)
.color(egui::Color32::from_gray(160))
.size(11.5),
);
}
ui.add_space(6.0);
let description = if has_pick {
format!(
"Hand the captured region straight to {app_name} \
for annotation, save, and share. While enabled, \
the options below are managed by {app_name}."
)
} else {
"Pick a screenshot app from the dropdown — or browse to \
a custom binary — and Vernier will hand captures \
off for annotation, save, and share."
.to_string()
};
ui.label(
egui::RichText::new(description)
.color(egui::Color32::from_gray(190))
.size(12.5),
);
ui.add_space(8.0);
ui.add_enabled_ui(has_pick, |ui| {
let resp = ui.checkbox(&mut s.handoff_enabled, "Enable");
if !has_pick {
resp.on_hover_text("Pick an app first");
}
});
});
});
});
}
fn paint_handoff_dropdown(
ui: &mut egui::Ui,
s: &mut ScreenshotSettings,
installed_apps: &[(HandoffApp, Option<egui::TextureHandle>)],
) {
let selected_text = if !s.handoff_command.is_empty() {
if !s.handoff_app_name.is_empty() {
s.handoff_app_name.clone()
} else {
"Custom".to_string()
}
} else {
"Choose app".to_string()
};
let row_count = installed_apps.len().max(1) as f32;
let popup_height = (row_count * 32.0 + 16.0).max(120.0);
egui::ComboBox::from_id_salt("handoff_app_picker")
.selected_text(selected_text)
.height(popup_height)
.show_ui(ui, |ui| {
if installed_apps.is_empty() {
ui.label(
egui::RichText::new("No known apps on $PATH")
.color(egui::Color32::from_gray(190))
.italics(),
);
ui.label(
egui::RichText::new("Use Browse… for a custom binary.")
.color(egui::Color32::from_gray(150))
.size(11.5),
);
return;
}
for (app, tex) in installed_apps {
let row = ui.horizontal(|ui| {
let w = ui.available_width();
ui.set_min_width(w);
if let Some(t) = tex {
ui.add(
egui::Image::new(t)
.fit_to_exact_size(egui::vec2(20.0, 20.0)),
);
ui.add_space(6.0);
} else {
ui.add_space(26.0);
}
ui.add(egui::Label::new(&app.name).selectable(false));
});
let row = row
.response
.interact(egui::Sense::click())
.on_hover_cursor(egui::CursorIcon::PointingHand);
if row.clicked() {
apply_handoff_app(s, app.clone());
s.handoff_enabled = true;
ui.close();
}
}
});
}
fn tick_slider(
ui: &mut egui::Ui,
value: &mut u32,
range: std::ops::RangeInclusive<u32>,
ticks: u32,
width: f32,
) -> egui::Response {
let height = 16.0;
let (rect, mut response) = ui.allocate_exact_size(
egui::vec2(width, height),
egui::Sense::click_and_drag(),
);
let knob_radius = 5.5;
let track_left = rect.left() + knob_radius;
let track_right = rect.right() - knob_radius;
let track_y = rect.center().y;
let track_span = track_right - track_left;
let range_min = *range.start() as f32;
let range_max = *range.end() as f32;
let range_span = (range_max - range_min).max(1.0);
if response.dragged() || response.clicked() {
if let Some(pos) = response.interact_pointer_pos() {
let t = ((pos.x - track_left) / track_span).clamp(0.0, 1.0);
let raw = range_min + t * range_span;
let new_val = raw.round().clamp(range_min, range_max) as u32;
if *value != new_val {
*value = new_val;
response.mark_changed();
}
}
}
let painter = ui.painter();
let visuals = ui.visuals();
let normalized = ((*value as f32 - range_min) / range_span).clamp(0.0, 1.0);
let knob_x = track_left + normalized * track_span;
let rail_color = egui::Color32::from_gray(60);
let rail_thickness = 3.0;
let rail_rect = egui::Rect::from_min_max(
egui::pos2(track_left, track_y - rail_thickness * 0.5),
egui::pos2(track_right, track_y + rail_thickness * 0.5),
);
painter.rect_filled(
rail_rect,
egui::CornerRadius::same(2),
rail_color,
);
let notch_color = egui::Color32::from_gray(115);
let half_notch = 4.0;
let notch_stroke = egui::Stroke::new(1.0, notch_color);
for i in 0..ticks {
let t = if ticks > 1 {
i as f32 / (ticks - 1) as f32
} else {
0.5
};
let x = (track_left + t * track_span).round() + 0.5;
painter.line_segment(
[
egui::pos2(x, track_y - half_notch),
egui::pos2(x, track_y + half_notch),
],
notch_stroke,
);
}
let knob_visuals = if response.dragged() {
visuals.widgets.active
} else if response.hovered() {
visuals.widgets.hovered
} else {
visuals.widgets.inactive
};
painter.circle(
egui::pos2(knob_x, track_y),
knob_radius,
knob_visuals.bg_fill,
knob_visuals.bg_stroke,
);
response
}
fn tolerance_section(ui: &mut egui::Ui, s: &mut ToleranceSettings) {
caption(ui,
"Numeric value (sum-of-channel difference, 0–255) for each tolerance level. \
Live + / − cycles between levels in a session; the dropdown picks which one \
is active each time measure mode opens.",
);
ui.add_space(14.0);
const TICK_COUNT: u32 = 16;
let row = |ui: &mut egui::Ui, label: &str, value: &mut u32| {
ui.horizontal(|ui| {
let label_w = 90.0;
let resp = ui.allocate_response(egui::vec2(label_w, 22.0), egui::Sense::hover());
ui.painter().text(
resp.rect.right_center(),
egui::Align2::RIGHT_CENTER,
label,
egui::FontId::proportional(14.0),
ui.visuals().text_color(),
);
ui.add_space(12.0);
tick_slider(ui, value, 0..=255, TICK_COUNT, 320.0);
ui.add_space(10.0);
ui.label(
egui::RichText::new(format!("{value}"))
.monospace()
.color(ui.visuals().weak_text_color()),
);
});
ui.add_space(8.0);
};
row(ui, "Zero", &mut s.zero_value);
row(ui, "Low", &mut s.low_value);
row(ui, "Medium", &mut s.medium_value);
row(ui, "High", &mut s.high_value);
ui.add_space(10.0);
ui.horizontal(|ui| {
ui.label("Default tolerance:");
egui::ComboBox::from_id_salt("default_tolerance_combo")
.selected_text(s.default_level.label())
.show_ui(ui, |ui| {
for level in [
ToleranceLevel::Zero,
ToleranceLevel::Low,
ToleranceLevel::Medium,
ToleranceLevel::High,
] {
ui.selectable_value(&mut s.default_level, level, level.label());
}
});
ui.with_layout(
egui::Layout::right_to_left(egui::Align::Center),
|ui| {
if ui.button("Restore Defaults").clicked() {
*s = ToleranceSettings::default();
}
},
);
});
}
fn appearance_section(ui: &mut egui::Ui, s: &mut AppearanceSettings) {
setting(ui, |ui| {
field_label(ui, "Primary color");
color_picker(ui, &mut s.primary_color);
});
setting(ui, |ui| {
field_label(ui, "Alternative color (toggled with `x`)");
color_picker(ui, &mut s.alternative_color);
});
setting(ui, |ui| {
field_label(ui, "Guide color");
color_picker(ui, &mut s.guide_color);
});
setting(ui, |ui| {
field_label(ui, "Alternative guide color (toggled with `x` while placing)");
color_picker(ui, &mut s.alternative_guide_color);
});
ui.add_space(12.0);
if ui.button("Restore Defaults").clicked() {
*s = AppearanceSettings::default();
}
}
fn integrations_section(ui: &mut egui::Ui, s: &mut IntegrationSettings) {
paint_figma_card(ui, s);
}
fn paint_figma_card(ui: &mut egui::Ui, s: &mut IntegrationSettings) {
egui::Frame::group(ui.style())
.fill(egui::Color32::from_gray(34))
.stroke(egui::Stroke::new(1.0, egui::Color32::from_gray(60)))
.corner_radius(egui::CornerRadius::same(10))
.inner_margin(egui::Margin::symmetric(18, 16))
.show(ui, |ui| {
ui.horizontal(|ui| {
let (rect, _) = ui.allocate_exact_size(
egui::vec2(72.0, 72.0),
egui::Sense::hover(),
);
ui.painter().rect_filled(
rect,
egui::CornerRadius::same(14),
egui::Color32::from_gray(50),
);
ui.painter().text(
rect.center(),
egui::Align2::CENTER_CENTER,
"F",
egui::FontId::proportional(36.0),
egui::Color32::from_gray(170),
);
ui.add_space(14.0);
ui.vertical(|ui| {
ui.label(
egui::RichText::new("Figma integration")
.size(16.0)
.strong(),
);
ui.add_space(4.0);
ui.label(
egui::RichText::new(
"Reports the active Figma file's viewport zoom \
over a localhost WebSocket so on-screen \
measurements come back in canvas pixels rather \
than zoomed screen pixels. Requires a one-time \
plugin install per machine.",
)
.color(egui::Color32::from_gray(190))
.size(12.5),
);
ui.add_space(8.0);
ui.checkbox(&mut s.figma_zoom_correction, "Enable")
.on_hover_text(
"When off, the daemon ignores the plugin and \
measurements always reflect raw screen pixels.",
);
ui.add_space(8.0);
let connected = vernier_platform::figma_bridge::current_figma_zoom()
.is_some();
ui.horizontal(|ui| {
let (dot_rect, _) = ui.allocate_exact_size(
egui::vec2(10.0, 10.0),
egui::Sense::hover(),
);
let (color, label) = if connected {
(
egui::Color32::from_rgb(80, 200, 120),
"Plugin connected",
)
} else {
(
egui::Color32::from_gray(120),
"Plugin not connected",
)
};
ui.painter().circle_filled(dot_rect.center(), 5.0, color);
ui.label(
egui::RichText::new(label)
.color(egui::Color32::from_gray(200))
.size(12.5),
);
});
ui.add_space(8.0);
ui.label(
egui::RichText::new(
"The button below copies the plugin manifest \
path to your clipboard and opens Figma. In \
Figma, open the main menu, then Plugins > \
Development > Import plugin from manifest..., \
and paste the path.",
)
.color(egui::Color32::from_gray(170))
.size(12.0),
);
ui.add_space(8.0);
let manifest = vernier_platform::figma_bridge::manifest_path();
ui.add_enabled_ui(manifest.is_some(), |ui| {
if ui.button("Install plugin in Figma…").clicked() {
if let Some(path) = manifest.as_ref() {
ui.ctx().copy_text(path.display().to_string());
open_figma_in_browser();
log::info!(
"figma plugin: copied manifest path {} \
and launched browser",
path.display()
);
}
}
});
if manifest.is_none() {
ui.add_space(4.0);
ui.label(
egui::RichText::new(
"Plugin files not found next to the binary. \
Set $VERNIER_FIGMA_PLUGIN_DIR to the \
directory containing manifest.json.",
)
.color(egui::Color32::from_rgb(220, 160, 90))
.size(11.5),
);
}
});
});
});
}
fn open_figma_in_browser() {
use std::process::{Command, Stdio};
let _ = Command::new("xdg-open")
.arg("https://www.figma.com/files/recent")
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn();
}
fn shortcuts_section(
ui: &mut egui::Ui,
s: &mut ShortcutSettings,
capturing: &mut Option<ShortcutId>,
static_bind_warning: Option<&std::path::Path>,
) {
if let Some(path) = static_bind_warning {
egui::Frame::NONE
.fill(egui::Color32::from_rgb(60, 48, 16))
.stroke(egui::Stroke::new(1.0, egui::Color32::from_rgb(180, 140, 50)))
.corner_radius(egui::CornerRadius::same(8))
.inner_margin(egui::Margin::symmetric(12, 10))
.show(ui, |ui| {
ui.label(
egui::RichText::new("⚠ Static bind detected")
.color(egui::Color32::from_rgb(255, 200, 90))
.size(13.5)
.strong(),
);
ui.add_space(4.0);
ui.label(
egui::RichText::new(format!(
"A line in {} runs `vernier toggle`. It fires regardless of \
what's set here — remove that line if you want only the \
shortcut configured below.",
path.display()
))
.color(egui::Color32::from_gray(220))
.size(12.5),
);
ui.add_space(8.0);
if ui
.add(egui::Button::new(
egui::RichText::new("Open in editor").size(12.5),
))
.clicked()
{
open_in_editor(path);
}
});
ui.add_space(12.0);
}
shortcut_row(
ui,
"Toggle measure mode",
"Show or hide the measurement overlay. Global hotkey — fires \
even when vernier doesn't have focus.",
&mut s.toggle,
ShortcutId::Toggle,
capturing,
);
shortcut_row(
ui,
"Exit measure mode",
"Leave measure mode in a single press. Held rects, guides, and \
stuck measurements are preserved — they stay visible in the \
passthrough overlay, so this never wipes your session. To clear \
content, use the right-click \"Clear\" menu item.",
&mut s.clear_and_hide,
ShortcutId::ClearAndHide,
capturing,
);
ui.add_space(8.0);
shortcut_row(
ui,
"Clear & exit",
"Clear every held rect, guide, and stuck measurement, then \
leave measure mode — in a single press. Only active while \
you're measuring, so it won't clash with the same combo in \
your other apps.",
&mut s.clear_and_exit,
ShortcutId::ClearAndExit,
capturing,
);
ui.add_space(8.0);
shortcut_row(
ui,
"Restore session",
"Reload the held rects, guides, and stuck measurements from \
the last saved session.",
&mut s.restore_session,
ShortcutId::Restore,
capturing,
);
shortcut_row(
ui,
"Capture (copy dimensions)",
"Copy the W×H of the hovered held rect (or the only one if \
there's just one) to the clipboard, formatted per the \
Integrations pane.",
&mut s.capture,
ShortcutId::Capture,
capturing,
);
shortcut_row(
ui,
"Crosshair mode",
"While this modifier is held, the overlay extends the axis \
lines to the screen edges and suppresses measurement \
readouts — gives you a clean alignment crosshair to line \
elements up against.",
&mut s.crosshair_mode,
ShortcutId::Crosshair,
capturing,
);
shortcut_row(
ui,
"Place horizontal guide",
"Arm a horizontal guide line — the next click commits it at \
the cursor's y. Useful as a measurement anchor.",
&mut s.guide_horizontal,
ShortcutId::GuideHorizontal,
capturing,
);
shortcut_row(
ui,
"Place vertical guide",
"Arm a vertical guide line — the next click commits it at \
the cursor's x.",
&mut s.guide_vertical,
ShortcutId::GuideVertical,
capturing,
);
shortcut_row(
ui,
"Toggle HUD color",
"Swap the overlay foreground between the primary color (coral \
red by default) and the alternate (black). Helps when the UI \
underneath clashes with one of the two.",
&mut s.color_toggle,
ShortcutId::ColorToggle,
capturing,
);
shortcut_row(
ui,
"Stuck horizontal measurement",
"Freeze the live crosshair's horizontal extent in place with \
the pixel distance pinned to it. Stays visible until cleared.",
&mut s.stuck_horizontal,
ShortcutId::StuckHorizontal,
capturing,
);
shortcut_row(
ui,
"Stuck vertical measurement",
"Freeze the live crosshair's vertical extent in place with \
the pixel distance pinned to it.",
&mut s.stuck_vertical,
ShortcutId::StuckVertical,
capturing,
);
shortcut_row(
ui,
"Refresh capture",
"Recapture the screen so subsequent edge detection uses the \
current content (e.g. after the underlying app updates).",
&mut s.refresh_capture,
ShortcutId::RefreshCapture,
capturing,
);
shortcut_row(
ui,
"Tolerance up",
"Bump the edge-detection tolerance one level higher. More \
tolerant snaps merge across small color differences.",
&mut s.tolerance_up,
ShortcutId::ToleranceUp,
capturing,
);
shortcut_row(
ui,
"Tolerance down",
"Bump the edge-detection tolerance one level lower. Stricter \
snaps stop at smaller color differences.",
&mut s.tolerance_down,
ShortcutId::ToleranceDown,
capturing,
);
shortcut_row(
ui,
"Nudge held rect left",
"Move the hovered held rect 1 px left. Hold Shift for 10 px.",
&mut s.nudge_left,
ShortcutId::NudgeLeft,
capturing,
);
shortcut_row(
ui,
"Nudge held rect right",
"Move the hovered held rect 1 px right. Hold Shift for 10 px.",
&mut s.nudge_right,
ShortcutId::NudgeRight,
capturing,
);
shortcut_row(
ui,
"Nudge held rect up",
"Move the hovered held rect 1 px up. Hold Shift for 10 px.",
&mut s.nudge_up,
ShortcutId::NudgeUp,
capturing,
);
shortcut_row(
ui,
"Nudge held rect down",
"Move the hovered held rect 1 px down. Hold Shift for 10 px.",
&mut s.nudge_down,
ShortcutId::NudgeDown,
capturing,
);
shortcut_row(
ui,
"Take normal screenshot",
"Run the External Screenshot Tool action (also available \
from the right-click menu). Same teardown as Esc, then \
detached spawn of the command in the Screenshots pane.",
&mut s.take_normal_screenshot,
ShortcutId::TakeNormalScreenshot,
capturing,
);
ui.add_space(4.0);
caption(ui,
"Nudge shortcuts: hold Shift to step 10 px instead of 1 px (built-in modifier).",
);
ui.add_space(12.0);
if ui.button("Restore Defaults").clicked() {
*s = ShortcutSettings::default();
*capturing = None;
}
}
enum CaptureOutcome {
Cancel,
Commit(String),
}
fn capture_outcome(i: &mut egui::InputState, target: ShortcutId) -> Option<CaptureOutcome> {
let esc_is_cancel = !matches!(target, ShortcutId::ClearAndHide);
if esc_is_cancel {
let escaped = i.events.iter().any(|ev| {
matches!(
ev,
egui::Event::Key {
key: egui::Key::Escape,
pressed: true,
modifiers,
..
} if !modifiers.shift && !modifiers.ctrl && !modifiers.alt
&& !modifiers.command && !modifiers.mac_cmd
)
});
if escaped {
i.events.retain(|ev| !matches!(ev, egui::Event::Key { .. }));
return Some(CaptureOutcome::Cancel);
}
}
if matches!(target, ShortcutId::Crosshair) {
let m = i.modifiers;
let token = if m.shift {
Some("SHIFT")
} else if m.ctrl {
Some("CTRL")
} else if m.alt {
Some("ALT")
} else if m.command || m.mac_cmd {
Some("SUPER")
} else {
None
};
if let Some(t) = token {
i.events.retain(|ev| !matches!(ev, egui::Event::Key { .. }));
return Some(CaptureOutcome::Commit(t.to_string()));
}
return None;
}
let result = i.events.iter().find_map(|ev| match ev {
egui::Event::Key {
key,
pressed: true,
modifiers,
..
} => Some(format_accelerator(*key, *modifiers)),
_ => None,
});
i.events.retain(|ev| !matches!(ev, egui::Event::Key { .. }));
result.map(CaptureOutcome::Commit)
}
#[derive(Clone, Debug)]
enum ChipSeg {
Letter(String),
OmarchyLogo,
Shift,
Ctrl,
Alt,
Enter,
Arrow(ArrowDir),
Plus,
Minus,
Equal,
Underscore,
}
#[derive(Clone, Copy, Debug)]
enum ArrowDir {
Up,
Down,
Left,
Right,
}
fn shortcut_chip_segments(stored: &str) -> Vec<ChipSeg> {
#[cfg(not(target_os = "macos"))]
let omarchy = omarchy_font_available();
#[cfg(target_os = "macos")]
let mac_glyph = |g: &str| ChipSeg::Letter(g.to_string());
stored
.split('+')
.filter(|t| !t.is_empty())
.map(|tok| match tok {
"SHIFT" => {
#[cfg(target_os = "macos")]
{
mac_glyph("\u{21E7}")
}
#[cfg(not(target_os = "macos"))]
{
ChipSeg::Shift
}
}
"CTRL" => {
#[cfg(target_os = "macos")]
{
mac_glyph("\u{2303}")
}
#[cfg(not(target_os = "macos"))]
{
ChipSeg::Ctrl
}
}
"ALT" => {
#[cfg(target_os = "macos")]
{
mac_glyph("\u{2325}")
}
#[cfg(not(target_os = "macos"))]
{
ChipSeg::Alt
}
}
"SUPER" => {
#[cfg(target_os = "macos")]
{
mac_glyph("\u{2318}")
}
#[cfg(not(target_os = "macos"))]
{
if omarchy {
ChipSeg::OmarchyLogo
} else {
ChipSeg::Letter("SUPER".to_string())
}
}
}
"ENTER" | "RETURN" => ChipSeg::Enter,
"LEFT" => ChipSeg::Arrow(ArrowDir::Left),
"RIGHT" => ChipSeg::Arrow(ArrowDir::Right),
"UP" => ChipSeg::Arrow(ArrowDir::Up),
"DOWN" => ChipSeg::Arrow(ArrowDir::Down),
"PLUS" => ChipSeg::Plus,
"MINUS" => ChipSeg::Minus,
"EQUAL" => ChipSeg::Equal,
"UNDERSCORE" => ChipSeg::Underscore,
other => ChipSeg::Letter(other.to_string()),
})
.collect()
}
const CHIP_GLYPH_SIZE: f32 = 14.0; const CHIP_LETTER_PT: f32 = 15.0; const CHIP_GAP: f32 = 6.0;
fn segment_advance(seg: &ChipSeg, ctx: &egui::Context) -> f32 {
match seg {
ChipSeg::Letter(s) => measure_chip_text(ctx, s, CHIP_LETTER_PT),
ChipSeg::OmarchyLogo => measure_chip_text(ctx, "\u{e900}", CHIP_LETTER_PT),
ChipSeg::Shift
| ChipSeg::Ctrl
| ChipSeg::Alt
| ChipSeg::Enter
| ChipSeg::Arrow(_) => CHIP_GLYPH_SIZE,
ChipSeg::Plus | ChipSeg::Minus | ChipSeg::Equal | ChipSeg::Underscore => {
CHIP_GLYPH_SIZE
}
}
}
fn measure_chip_text(ctx: &egui::Context, text: &str, size: f32) -> f32 {
let family = egui::FontFamily::Name("shortcut".into());
ctx.fonts(|f| {
let font_id = egui::FontId::new(size, family);
text.chars()
.map(|c| f.glyph_width(&font_id, c))
.sum::<f32>()
})
}
fn paint_shortcut_chip(
ui: &mut egui::Ui,
chip_rect: egui::Rect,
bg: egui::Color32,
fg: egui::Color32,
segments: &[ChipSeg],
) {
let painter = ui.painter().with_clip_rect(chip_rect);
painter.rect_filled(chip_rect, egui::CornerRadius::same(4), bg);
if segments.is_empty() {
return;
}
let ctx = ui.ctx().clone();
let widths: Vec<f32> = segments.iter().map(|s| segment_advance(s, &ctx)).collect();
let total: f32 = widths.iter().sum::<f32>() + CHIP_GAP * (segments.len() as f32 - 1.0);
let mut cursor_x = chip_rect.center().x - total / 2.0;
let cy = chip_rect.center().y;
let letter_font = egui::FontId::new(CHIP_LETTER_PT, egui::FontFamily::Name("shortcut".into()));
for (seg, w) in segments.iter().zip(widths.iter()) {
let glyph_rect = egui::Rect::from_center_size(
egui::pos2(cursor_x + w / 2.0, cy),
egui::vec2(*w, CHIP_GLYPH_SIZE),
);
match seg {
ChipSeg::Letter(s) => {
painter.text(
glyph_rect.center(),
egui::Align2::CENTER_CENTER,
s,
letter_font.clone(),
fg,
);
}
ChipSeg::OmarchyLogo => {
painter.text(
glyph_rect.center(),
egui::Align2::CENTER_CENTER,
"\u{e900}",
letter_font.clone(),
fg,
);
}
ChipSeg::Shift => paint_shift(&painter, glyph_rect, fg),
ChipSeg::Ctrl => paint_caret(&painter, glyph_rect, fg),
ChipSeg::Alt => paint_alt(&painter, glyph_rect, fg),
ChipSeg::Enter => paint_enter(&painter, glyph_rect, fg),
ChipSeg::Arrow(dir) => paint_arrow(&painter, glyph_rect, fg, *dir),
ChipSeg::Plus => paint_plus(&painter, glyph_rect, fg),
ChipSeg::Minus => paint_minus(&painter, glyph_rect, fg),
ChipSeg::Equal => paint_equal(&painter, glyph_rect, fg),
ChipSeg::Underscore => paint_underscore(&painter, glyph_rect, fg),
}
cursor_x += w + CHIP_GAP;
}
}
fn paint_shift(painter: &egui::Painter, rect: egui::Rect, color: egui::Color32) {
let w = rect.width();
let h = rect.height();
let cx = rect.center().x;
let top = rect.top() + h * 0.15;
let mid = rect.top() + h * 0.55;
let bot = rect.top() + h * 0.85;
let stem_half = w * 0.18;
let cap_half = w * 0.36;
let pts = vec![
egui::pos2(cx, top),
egui::pos2(cx + cap_half, mid),
egui::pos2(cx + stem_half, mid),
egui::pos2(cx + stem_half, bot),
egui::pos2(cx - stem_half, bot),
egui::pos2(cx - stem_half, mid),
egui::pos2(cx - cap_half, mid),
];
let shape = egui::epaint::PathShape::closed_line(pts, egui::Stroke::new(1.8, color));
painter.add(egui::Shape::Path(shape));
}
fn paint_caret(painter: &egui::Painter, rect: egui::Rect, color: egui::Color32) {
let w = rect.width();
let h = rect.height();
let cx = rect.center().x;
let apex_y = rect.top() + h * 0.22;
let foot_y = rect.top() + h * 0.55;
let stroke = egui::Stroke::new(2.4, color);
painter.line_segment(
[
egui::pos2(cx - w * 0.40, foot_y),
egui::pos2(cx, apex_y),
],
stroke,
);
painter.line_segment(
[
egui::pos2(cx, apex_y),
egui::pos2(cx + w * 0.40, foot_y),
],
stroke,
);
}
fn paint_alt(painter: &egui::Painter, rect: egui::Rect, color: egui::Color32) {
let w = rect.width();
let h = rect.height();
let stroke = egui::Stroke::new(2.0, color);
let top_y = rect.top() + h * 0.15;
let bot_y = rect.top() + h * 0.85;
let left = rect.left();
let corner_top_x = left + w * 0.35;
let corner_bot_x = left + w * 0.52;
painter.line_segment(
[egui::pos2(left + w * 0.02, top_y), egui::pos2(corner_top_x, top_y)],
stroke,
);
painter.line_segment(
[egui::pos2(corner_top_x, top_y), egui::pos2(corner_bot_x, bot_y)],
stroke,
);
painter.line_segment(
[egui::pos2(corner_bot_x, bot_y), egui::pos2(left + w * 0.92, bot_y)],
stroke,
);
painter.line_segment(
[egui::pos2(left + w * 0.64, top_y), egui::pos2(left + w * 0.92, top_y)],
stroke,
);
}
fn paint_enter(painter: &egui::Painter, rect: egui::Rect, color: egui::Color32) {
let w = rect.width();
let h = rect.height();
let stroke = egui::Stroke::new(2.0, color);
let top_y = rect.top() + h * 0.25;
let mid_y = rect.top() + h * 0.65;
let left_x = rect.left() + w * 0.12;
let right_x = rect.right() - w * 0.10;
painter.line_segment(
[egui::pos2(right_x, top_y), egui::pos2(right_x, mid_y)],
stroke,
);
painter.line_segment(
[egui::pos2(right_x, mid_y), egui::pos2(left_x + 2.0, mid_y)],
stroke,
);
let head = vec![
egui::pos2(left_x, mid_y),
egui::pos2(left_x + 4.0, mid_y - 3.0),
egui::pos2(left_x + 4.0, mid_y + 3.0),
];
painter.add(egui::Shape::Path(
egui::epaint::PathShape::convex_polygon(head, color, egui::Stroke::NONE),
));
}
fn paint_arrow(painter: &egui::Painter, rect: egui::Rect, color: egui::Color32, dir: ArrowDir) {
let w = rect.width();
let h = rect.height();
let stroke = egui::Stroke::new(2.0, color);
let half_head = (w.min(h)) * 0.30;
match dir {
ArrowDir::Up => {
let cx = rect.center().x;
let tail_y = rect.bottom() - h * 0.10;
let head_base_y = rect.top() + h * 0.40;
let head_tip_y = rect.top() + h * 0.05;
painter.line_segment(
[egui::pos2(cx, tail_y), egui::pos2(cx, head_base_y)],
stroke,
);
let head = vec![
egui::pos2(cx, head_tip_y),
egui::pos2(cx - half_head, head_base_y),
egui::pos2(cx + half_head, head_base_y),
];
painter.add(egui::Shape::Path(
egui::epaint::PathShape::convex_polygon(head, color, egui::Stroke::NONE),
));
}
ArrowDir::Down => {
let cx = rect.center().x;
let tail_y = rect.top() + h * 0.10;
let head_base_y = rect.bottom() - h * 0.40;
let head_tip_y = rect.bottom() - h * 0.05;
painter.line_segment(
[egui::pos2(cx, tail_y), egui::pos2(cx, head_base_y)],
stroke,
);
let head = vec![
egui::pos2(cx, head_tip_y),
egui::pos2(cx - half_head, head_base_y),
egui::pos2(cx + half_head, head_base_y),
];
painter.add(egui::Shape::Path(
egui::epaint::PathShape::convex_polygon(head, color, egui::Stroke::NONE),
));
}
ArrowDir::Right => {
let cy = rect.center().y;
let tail_x = rect.left() + w * 0.10;
let head_base_x = rect.right() - w * 0.40;
let head_tip_x = rect.right() - w * 0.05;
painter.line_segment(
[egui::pos2(tail_x, cy), egui::pos2(head_base_x, cy)],
stroke,
);
let head = vec![
egui::pos2(head_tip_x, cy),
egui::pos2(head_base_x, cy - half_head),
egui::pos2(head_base_x, cy + half_head),
];
painter.add(egui::Shape::Path(
egui::epaint::PathShape::convex_polygon(head, color, egui::Stroke::NONE),
));
}
ArrowDir::Left => {
let cy = rect.center().y;
let tail_x = rect.right() - w * 0.10;
let head_base_x = rect.left() + w * 0.40;
let head_tip_x = rect.left() + w * 0.05;
painter.line_segment(
[egui::pos2(tail_x, cy), egui::pos2(head_base_x, cy)],
stroke,
);
let head = vec![
egui::pos2(head_tip_x, cy),
egui::pos2(head_base_x, cy - half_head),
egui::pos2(head_base_x, cy + half_head),
];
painter.add(egui::Shape::Path(
egui::epaint::PathShape::convex_polygon(head, color, egui::Stroke::NONE),
));
}
}
}
const CHIP_BAR_THICKNESS: f32 = 1.8;
fn paint_minus(painter: &egui::Painter, rect: egui::Rect, color: egui::Color32) {
let w = rect.width();
let bar = egui::Rect::from_center_size(
rect.center(),
egui::vec2(w * 0.80, CHIP_BAR_THICKNESS),
);
painter.rect_filled(bar, egui::CornerRadius::same(1), color);
}
fn paint_plus(painter: &egui::Painter, rect: egui::Rect, color: egui::Color32) {
let w = rect.width();
let h = rect.height();
let horiz = egui::Rect::from_center_size(
rect.center(),
egui::vec2(w * 0.80, CHIP_BAR_THICKNESS),
);
let vert = egui::Rect::from_center_size(
rect.center(),
egui::vec2(CHIP_BAR_THICKNESS, h * 0.80),
);
painter.rect_filled(horiz, egui::CornerRadius::same(1), color);
painter.rect_filled(vert, egui::CornerRadius::same(1), color);
}
fn paint_equal(painter: &egui::Painter, rect: egui::Rect, color: egui::Color32) {
let w = rect.width();
let h = rect.height();
let cx = rect.center().x;
let top = egui::Rect::from_center_size(
egui::pos2(cx, rect.center().y - h * 0.16),
egui::vec2(w * 0.80, CHIP_BAR_THICKNESS),
);
let bot = egui::Rect::from_center_size(
egui::pos2(cx, rect.center().y + h * 0.16),
egui::vec2(w * 0.80, CHIP_BAR_THICKNESS),
);
painter.rect_filled(top, egui::CornerRadius::same(1), color);
painter.rect_filled(bot, egui::CornerRadius::same(1), color);
}
fn paint_underscore(painter: &egui::Painter, rect: egui::Rect, color: egui::Color32) {
let w = rect.width();
let h = rect.height();
let bar = egui::Rect::from_center_size(
egui::pos2(rect.center().x, rect.bottom() - h * 0.10),
egui::vec2(w * 0.80, CHIP_BAR_THICKNESS),
);
painter.rect_filled(bar, egui::CornerRadius::same(1), color);
}
fn shortcut_row(
ui: &mut egui::Ui,
label: &str,
tooltip: &str,
value: &mut String,
id: ShortcutId,
capturing: &mut Option<ShortcutId>,
) {
ui.horizontal(|ui| {
let label_w = 200.0;
let resp = ui
.allocate_response(egui::vec2(label_w, 28.0), egui::Sense::hover())
.on_hover_text(tooltip);
ui.painter().text(
resp.rect.left_center(),
egui::Align2::LEFT_CENTER,
label,
egui::FontId::proportional(14.0),
ui.visuals().text_color(),
);
let is_capturing = *capturing == Some(id);
let chip_size = egui::vec2(200.0, 28.0);
let chip_resp = ui.allocate_response(chip_size, egui::Sense::click());
let chip_rect = chip_resp.rect;
let bg = if is_capturing {
egui::Color32::from_rgb(50, 90, 140)
} else if chip_resp.hovered() {
egui::Color32::from_gray(74)
} else if value.is_empty() {
egui::Color32::from_gray(40)
} else {
egui::Color32::from_gray(64)
};
let fg = egui::Color32::WHITE;
if is_capturing {
paint_shortcut_chip(
ui,
chip_rect,
bg,
fg,
&[ChipSeg::Letter("Press a shortcut…".into())],
);
} else if value.is_empty() {
paint_shortcut_chip(
ui,
chip_rect,
bg,
fg,
&[ChipSeg::Letter("Click to set".into())],
);
} else {
let segments = shortcut_chip_segments(value);
paint_shortcut_chip(ui, chip_rect, bg, fg, &segments);
}
if chip_resp.clicked() {
value.clear();
*capturing = Some(id);
}
});
ui.add_space(12.0);
}
fn open_in_editor(path: &std::path::Path) {
use std::process::{Command, Stdio};
let path_str = path.to_string_lossy().into_owned();
let desktop_id = Command::new("xdg-mime")
.args(["query", "default", "text/plain"])
.output()
.ok()
.filter(|o| o.status.success())
.and_then(|o| String::from_utf8(o.stdout).ok())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
if let Some(desktop_id) = desktop_id {
if let Some((exec, terminal)) = read_desktop_exec(&desktop_id) {
let argv = parse_exec_argv(&exec, &path_str);
if terminal {
if launch_in_terminal(&argv) {
log::info!("opened {} via terminal handler {}", path_str, desktop_id);
return;
}
} else if !argv.is_empty() {
if Command::new(&argv[0])
.args(&argv[1..])
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.is_ok()
{
log::info!("opened {} via {} ({})", path_str, argv[0], desktop_id);
return;
}
}
}
let app_name = desktop_id.strip_suffix(".desktop").unwrap_or(&desktop_id);
if Command::new("gtk-launch")
.args([app_name, &path_str])
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.is_ok()
{
log::info!("opened {} via gtk-launch {}", path_str, app_name);
return;
}
}
if Command::new("xdg-open")
.arg(&path_str)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.is_ok()
{
log::info!("opened {} via xdg-open", path_str);
return;
}
log::warn!("couldn't open editor for {}", path_str);
}
fn read_desktop_exec(id: &str) -> Option<(String, bool)> {
let home = std::env::var_os("HOME").map(PathBuf::from);
let xdg_data_home = std::env::var_os("XDG_DATA_HOME")
.map(PathBuf::from)
.or_else(|| home.as_ref().map(|h| h.join(".local/share")));
let mut roots: Vec<PathBuf> = Vec::new();
if let Some(p) = xdg_data_home {
roots.push(p);
}
if let Some(extra) = std::env::var_os("XDG_DATA_DIRS") {
for entry in std::env::split_paths(&extra) {
roots.push(entry);
}
} else {
roots.push(PathBuf::from("/usr/local/share"));
roots.push(PathBuf::from("/usr/share"));
}
for root in roots {
let candidate = root.join("applications").join(id);
if let Ok(text) = std::fs::read_to_string(&candidate) {
let mut exec: Option<String> = None;
let mut terminal = false;
let mut in_entry = false;
for line in text.lines() {
let line = line.trim();
if line.starts_with('[') {
in_entry = line.eq_ignore_ascii_case("[Desktop Entry]");
continue;
}
if !in_entry {
continue;
}
if let Some(rest) = line.strip_prefix("Exec=") {
exec = Some(rest.to_string());
} else if let Some(rest) = line.strip_prefix("Terminal=") {
terminal = matches!(rest.trim().to_ascii_lowercase().as_str(), "true" | "1");
}
}
if let Some(e) = exec {
return Some((e, terminal));
}
}
}
None
}
fn parse_exec_argv(exec: &str, file_path: &str) -> Vec<String> {
let mut argv: Vec<String> = Vec::new();
let mut current = String::new();
let mut in_quotes = false;
let mut chars = exec.chars().peekable();
while let Some(c) = chars.next() {
match c {
'"' => in_quotes = !in_quotes,
'\\' if in_quotes => {
if let Some(next) = chars.next() {
current.push(next);
}
}
' ' if !in_quotes => {
if !current.is_empty() {
argv.push(std::mem::take(&mut current));
}
}
_ => current.push(c),
}
}
if !current.is_empty() {
argv.push(current);
}
let mut out = Vec::with_capacity(argv.len());
for tok in argv {
match tok.as_str() {
"%f" | "%F" | "%u" | "%U" => out.push(file_path.to_string()),
"%i" | "%c" | "%k" => {} _ => out.push(tok),
}
}
out
}
fn launch_in_terminal(argv: &[String]) -> bool {
use std::process::{Command, Stdio};
if argv.is_empty() {
return false;
}
let try_terminal = |bin: &str, args_pre: &[&str]| -> bool {
let mut cmd = Command::new(bin);
for a in args_pre {
cmd.arg(a);
}
for a in argv {
cmd.arg(a);
}
cmd.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.is_ok()
};
if let Some(t) = std::env::var_os("TERMINAL") {
let bin = t.to_string_lossy().into_owned();
if try_terminal(&bin, &[]) {
return true;
}
}
if try_terminal("xdg-terminal-exec", &[]) {
return true;
}
for (bin, prefix) in [
("ghostty", &["-e"][..]),
("alacritty", &["-e"]),
("foot", &["-e"]),
("kitty", &[][..]), ("gnome-terminal", &["--", ]),
("konsole", &["-e"]),
("xterm", &["-e"]),
] {
if try_terminal(bin, prefix) {
return true;
}
}
false
}
fn format_accelerator(key: egui::Key, modifiers: egui::Modifiers) -> String {
let mut parts: Vec<&'static str> = Vec::new();
if modifiers.shift {
parts.push("SHIFT");
}
if modifiers.ctrl {
parts.push("CTRL");
}
if modifiers.alt {
parts.push("ALT");
}
if modifiers.command || modifiers.mac_cmd {
parts.push("SUPER");
}
let key_str = match key {
egui::Key::Space => "SPACE",
egui::Key::Enter => "ENTER",
egui::Key::Escape => "ESC",
egui::Key::Tab => "TAB",
egui::Key::Backspace => "BACKSPACE",
egui::Key::Delete => "DELETE",
egui::Key::ArrowUp => "UP",
egui::Key::ArrowDown => "DOWN",
egui::Key::ArrowLeft => "LEFT",
egui::Key::ArrowRight => "RIGHT",
egui::Key::Plus => "PLUS",
egui::Key::Minus => "MINUS",
egui::Key::Equals => "EQUAL",
_ => return finalize_with_key(parts, &key.name().to_uppercase()),
};
finalize_with_key(parts, key_str)
}
fn finalize_with_key(mut parts: Vec<&'static str>, key: &str) -> String {
let owned: Vec<String> = parts.drain(..).map(|s| s.to_string()).collect();
let mut out = owned.join("+");
if !out.is_empty() {
out.push('+');
}
out.push_str(key);
out
}
fn about_section(ui: &mut egui::Ui, logo: Option<&egui::TextureHandle>) {
ui.vertical_centered(|ui| {
ui.add_space(28.0);
if let Some(logo) = logo {
ui.add(egui::Image::new(logo).fit_to_exact_size(egui::vec2(112.0, 112.0)));
}
ui.add_space(18.0);
ui.label(egui::RichText::new("Vernier").size(28.0).strong());
ui.add_space(4.0);
ui.label(
egui::RichText::new(format!("Version {}", env!("CARGO_PKG_VERSION")))
.size(14.0)
.color(egui::Color32::from_gray(170)),
);
ui.add_space(20.0);
ui.label(
egui::RichText::new(
"A cross-platform Rust measurement overlay, targeting Hyprland on Omarchy.",
)
.size(14.0),
);
ui.add_space(8.0);
ui.hyperlink_to(
"github.com/jondkinney/vernier",
"https://github.com/jondkinney/vernier",
);
ui.add_space(20.0);
let path = vernier_core::settings_path();
let path_str = path.display().to_string();
let mut job = egui::text::LayoutJob::default();
job.append(
"Settings file: ",
0.0,
egui::TextFormat {
font_id: egui::FontId::proportional(12.0),
color: egui::Color32::from_gray(150),
..Default::default()
},
);
job.append(
&path_str,
0.0,
egui::TextFormat {
font_id: egui::FontId::proportional(12.0),
color: egui::Color32::from_rgb(0x4f, 0xa3, 0xff),
underline: egui::Stroke::new(1.0, egui::Color32::from_rgb(0x4f, 0xa3, 0xff)),
..Default::default()
},
);
let link = ui.add(egui::Label::new(job).sense(egui::Sense::click()));
if link.hovered() {
ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
}
if link.clicked() {
if let Err(e) = open_path_with_default_app(&path) {
log::warn!("failed to open {}: {e}", path.display());
}
}
ui.add_space(20.0);
});
}
fn open_path_with_default_app(path: &std::path::Path) -> std::io::Result<()> {
#[cfg(target_os = "macos")]
{
std::process::Command::new("/usr/bin/open").arg(path).spawn().map(|_| ())
}
#[cfg(target_os = "linux")]
{
std::process::Command::new("xdg-open").arg(path).spawn().map(|_| ())
}
#[cfg(target_os = "windows")]
{
std::process::Command::new("cmd")
.args(["/C", "start", ""])
.arg(path)
.spawn()
.map(|_| ())
}
}
fn setting<R>(ui: &mut egui::Ui, content: impl FnOnce(&mut egui::Ui) -> R) -> R {
let r = ui.vertical(|ui| content(ui)).inner;
ui.add_space(22.0);
r
}
fn available_screen_inner_height() -> f32 {
#[cfg(target_os = "macos")]
{
vernier_platform::primary_screen_visible_height().unwrap_or(1260.0)
}
#[cfg(not(target_os = "macos"))]
{
1260.0
}
}
fn alt_key_label() -> &'static str {
#[cfg(target_os = "macos")]
{
"Option"
}
#[cfg(not(target_os = "macos"))]
{
"Alt"
}
}
fn field_label(ui: &mut egui::Ui, text: &str) {
ui.label(egui::RichText::new(text).strong().size(14.0));
}
fn caption(ui: &mut egui::Ui, text: &str) {
use egui::text::LayoutJob;
const LINE_HEIGHT: f32 = 22.0;
let plain = egui::TextFormat {
font_id: egui::FontId::proportional(12.0),
color: egui::Color32::from_gray(170),
line_height: Some(LINE_HEIGHT),
valign: egui::Align::Center,
..Default::default()
};
let code = egui::TextFormat {
font_id: egui::FontId::monospace(11.5),
color: egui::Color32::from_gray(225),
line_height: Some(LINE_HEIGHT),
valign: egui::Align::Center,
..Default::default()
};
const NBSP: char = '\u{00A0}';
let mut job = LayoutJob::default();
job.wrap.max_width = ui.available_width();
let mut in_code = false;
let mut buf = String::new();
let flush = |job: &mut LayoutJob, buf: &mut String, in_code: bool| {
if buf.is_empty() {
return;
}
if in_code {
job.append(&format!("{NBSP}{buf}{NBSP}"), 0.0, code.clone());
} else {
job.append(buf, 0.0, plain.clone());
}
buf.clear();
};
for c in text.chars() {
if c == '`' {
flush(&mut job, &mut buf, in_code);
in_code = !in_code;
} else {
buf.push(c);
}
}
flush(&mut job, &mut buf, in_code);
let galley = ui.fonts(|f| f.layout_job(job));
let (rect, _resp) = ui.allocate_exact_size(galley.size(), egui::Sense::hover());
let origin = rect.min;
let painter = ui.painter();
let bg_color = egui::Color32::from_gray(48);
let bg_corner_radius: f32 = 3.0;
let bg_x_slop: f32 = 1.0;
let bg_y_pad_top: f32 = 2.0;
let bg_y_pad_bot: f32 = 1.0;
let mut in_code_run = false;
type CodeRun = (f32, f32, f32, f32); for row in &galley.rows {
let row_rect = row.rect();
let mut run: Option<CodeRun> = None;
let flush_run = |run: &mut Option<CodeRun>| {
if let Some((x0, x1, y_min, y_max)) = run.take() {
if y_min.is_finite() && y_max.is_finite() {
painter.rect_filled(
egui::Rect::from_min_max(
egui::pos2(x0 + bg_x_slop, y_min - bg_y_pad_top),
egui::pos2(x1 - bg_x_slop, y_max + bg_y_pad_bot),
),
bg_corner_radius,
bg_color,
);
}
}
};
for glyph in &row.glyphs {
let x0 = origin.x + row_rect.min.x + glyph.pos.x;
let size = glyph.size();
let x1 = x0 + size.x;
let baseline = origin.y + row_rect.min.y + glyph.pos.y;
let gy_min = baseline - glyph.font_ascent;
let gy_max = baseline + (glyph.font_height - glyph.font_ascent);
if glyph.chr == NBSP {
match run {
Some((_, ref mut x_end, _, _)) => *x_end = x1,
None => run = Some((x0, x1, f32::INFINITY, f32::NEG_INFINITY)),
}
if in_code_run {
in_code_run = false;
flush_run(&mut run);
} else {
in_code_run = true;
}
} else if in_code_run {
match run {
Some((_, ref mut x_end, ref mut y_min, ref mut y_max)) => {
*x_end = x1;
if gy_min < *y_min {
*y_min = gy_min;
}
if gy_max > *y_max {
*y_max = gy_max;
}
}
None => run = Some((x0, x1, gy_min, gy_max)),
}
}
}
flush_run(&mut run);
}
painter.galley(origin, galley, egui::Color32::PLACEHOLDER);
}
fn padded_text_edit(ui: &mut egui::Ui, text: &mut String) -> egui::Response {
ui.add(
egui::TextEdit::singleline(text)
.margin(egui::Margin::symmetric(8, 6))
.desired_width(f32::INFINITY),
)
}
fn color_picker(ui: &mut egui::Ui, c: &mut ColorRgba) {
let mut color = egui::Color32::from_rgba_unmultiplied(c.r, c.g, c.b, c.a);
if egui::color_picker::color_edit_button_srgba(
ui,
&mut color,
egui::color_picker::Alpha::OnlyBlend,
)
.changed()
{
c.r = color.r();
c.g = color.g();
c.b = color.b();
c.a = color.a();
}
ui.label(format!(
"#{:02X}{:02X}{:02X} (a={})",
c.r, c.g, c.b, c.a
));
}
pub fn run_prefs(
on_saved: Box<dyn FnMut() + Send>,
on_quit: Box<dyn FnMut() + Send>,
static_bind_warning: Option<PathBuf>,
) -> Result<()> {
let target_height = 1260.0f32.min(available_screen_inner_height());
let icon_data = {
let size = 256u32;
let rgba = vernier_platform::render_app_icon_rgba(size);
std::sync::Arc::new(egui::IconData { rgba, width: size, height: size })
};
let viewport = egui::ViewportBuilder::default()
.with_title("Vernier Preferences")
.with_app_id("vernier-prefs")
.with_min_inner_size([520.0, 360.0])
.with_inner_size([820.0, target_height])
.with_icon(icon_data);
let options = NativeOptions {
viewport,
vsync: false,
..Default::default()
};
eframe::run_native(
"Vernier Preferences",
options,
Box::new(move |cc| {
Ok(Box::new(PrefsApp::new(
cc,
on_saved,
on_quit,
static_bind_warning,
)))
}),
)
.map_err(|e| anyhow::anyhow!("eframe: {e}"))
}