use std::{
cell::{Cell, RefCell},
env,
f64::consts::{FRAC_PI_2, PI},
io::Read,
path::{Path, PathBuf},
process::{Child, Command, Stdio},
rc::Rc,
sync::mpsc,
thread,
time::{Duration, Instant},
};
#[cfg(unix)]
use std::os::unix::process::CommandExt;
use adw::prelude::*;
use anyhow::{Context, Result, ensure};
use gtk::{gio, glib};
use ocula::{pipeline::CaptureArea, selector};
use x11rb::{
COPY_DEPTH_FROM_PARENT, connect,
connection::Connection,
protocol::{
shape::{ConnectionExt as ShapeConnectionExt, SK, SO},
xproto::{
AtomEnum, ClipOrdering, Colormap, ConfigureWindowAux, ConnectionExt as XConnectionExt,
CreateWindowAux, ImageFormat, ImageOrder, PropMode, Rectangle, Screen, StackMode,
Visualid, Visualtype, Window, WindowClass,
},
},
rust_connection::RustConnection,
wrapper::ConnectionExt as _,
};
const APP_ID: &str = "dev.ocula.Ocula";
const LOG_CHANNEL_CAPACITY: usize = 256;
const LOG_SCROLLBACK_LINES: i32 = 500;
const LOG_SCROLLBACK_CHARS: i32 = 80_000;
const LOG_DRAIN_LINES_PER_TICK: usize = 64;
const LOG_READ_CHUNK_BYTES: usize = 1024;
const LOG_LINE_MAX_BYTES: usize = 4096;
const PREVIEW_CAPTURE_MAX_PIXELS: i64 = 8_294_400;
const CROP_OVERLAY_THICKNESS: i32 = 8;
const CROP_OVERLAY_EDGE_THICKNESS: i32 = 2;
const CROP_OVERLAY_OPACITY: u32 = 0xd800_0000;
const STOP_GRACE: Duration = Duration::from_secs(15);
const FORCE_KILL_GRACE: Duration = Duration::from_secs(5);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Mode {
Screen,
Window,
Area,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct SelectedArea {
area: CaptureArea,
screen_width: i32,
screen_height: i32,
}
struct PreviewSnapshot {
surface: gtk::cairo::ImageSurface,
width: i32,
height: i32,
}
#[derive(Debug, Clone, Copy)]
struct PreviewEffectStyle {
chip: &'static str,
primary: (f64, f64, f64),
secondary: (f64, f64, f64),
tertiary: (f64, f64, f64),
}
struct CropOverlay {
conn: RustConnection,
windows: Vec<Window>,
selected: SelectedArea,
}
impl Drop for CropOverlay {
fn drop(&mut self) {
destroy_crop_overlay_windows(&self.conn, &self.windows);
}
}
#[derive(Clone)]
struct Ui {
window: adw::ApplicationWindow,
controls_card: gtk::Box,
screen_button: gtk::ToggleButton,
window_button: gtk::ToggleButton,
area_button: gtk::ToggleButton,
audio_dropdown: gtk::DropDown,
hide_pointer_switch: gtk::Switch,
area_row: gtk::Box,
select_area_button: gtk::Button,
area_label: gtk::Label,
area_preview: gtk::DrawingArea,
format_dropdown: gtk::DropDown,
encoder_dropdown: gtk::DropDown,
effect_dropdown: gtk::DropDown,
output_entry: gtk::Entry,
record_button: gtk::Button,
status_label: gtk::Label,
log_buffer: gtk::TextBuffer,
selected_area: Rc<RefCell<Option<SelectedArea>>>,
preview_snapshot: Rc<RefCell<Option<PreviewSnapshot>>>,
crop_overlay: Rc<RefCell<Option<CropOverlay>>>,
crop_overlay_failed: Rc<Cell<Option<SelectedArea>>>,
child: Rc<RefCell<Option<Child>>>,
stop_started_at: Rc<RefCell<Option<Instant>>>,
force_stop_started_at: Rc<RefCell<Option<Instant>>>,
kill_sent: Rc<Cell<bool>>,
close_after_child_exit: Rc<Cell<bool>>,
window_destroyed: Rc<Cell<bool>>,
log_tx: mpsc::SyncSender<String>,
log_rx: Rc<RefCell<mpsc::Receiver<String>>>,
}
fn main() -> glib::ExitCode {
if version_requested(env::args().skip(1)) {
println!("ocula {}", env!("CARGO_PKG_VERSION"));
return glib::ExitCode::SUCCESS;
}
let app = adw::Application::builder().application_id(APP_ID).build();
app.connect_activate(build_ui);
app.run()
}
fn version_requested<I, S>(args: I) -> bool
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
args.into_iter()
.any(|arg| matches!(arg.as_ref(), "--version" | "-V"))
}
fn build_ui(app: &adw::Application) {
install_css();
let window = adw::ApplicationWindow::builder()
.application(app)
.title("Ocula")
.default_width(480)
.default_height(620)
.build();
window.add_css_class("ocula-window");
let toolbar = adw::ToolbarView::new();
let header = adw::HeaderBar::new();
header.set_title_widget(Some(&adw::WindowTitle::new("Ocula", "Safe screen capture")));
toolbar.add_top_bar(&header);
let root = gtk::Box::new(gtk::Orientation::Vertical, 12);
root.add_css_class("ocula-shell");
root.set_margin_top(18);
root.set_margin_bottom(18);
root.set_margin_start(18);
root.set_margin_end(18);
toolbar.set_content(Some(&root));
window.set_content(Some(&toolbar));
let hero = gtk::Box::new(gtk::Orientation::Vertical, 6);
hero.add_css_class("ocula-hero");
let title = gtk::Label::new(Some("Record without babysitting files"));
title.add_css_class("title-1");
title.set_xalign(0.0);
let subtitle = gtk::Label::new(Some(
"Pick what to capture, choose MKV or MP4, and Ocula writes a safe file.",
));
subtitle.add_css_class("dim-label");
subtitle.set_wrap(true);
subtitle.set_xalign(0.0);
hero.append(&title);
hero.append(&subtitle);
root.append(&hero);
let controls = card();
root.append(&controls);
let mode_label = section_label("Capture");
controls.append(&mode_label);
let mode_box = gtk::Box::new(gtk::Orientation::Horizontal, 8);
mode_box.add_css_class("linked");
let screen_button = mode_button("Screen");
let window_button = mode_button("Window");
let area_button = mode_button("Area");
window_button.set_group(Some(&screen_button));
area_button.set_group(Some(&screen_button));
screen_button.set_active(true);
mode_box.append(&screen_button);
mode_box.append(&window_button);
mode_box.append(&area_button);
controls.append(&mode_box);
let area_row = gtk::Box::new(gtk::Orientation::Horizontal, 12);
area_row.add_css_class("ocula-area-row");
area_row.set_valign(gtk::Align::Center);
let area_label = gtk::Label::new(Some("No area selected"));
area_label.set_xalign(0.0);
area_label.set_hexpand(true);
area_label.add_css_class("dim-label");
let select_area_button = gtk::Button::with_label("Pick Crop");
select_area_button.add_css_class("ocula-area-select-button");
area_row.append(&area_label);
area_row.append(&select_area_button);
area_row.set_visible(false);
controls.append(&area_row);
let selected_area = Rc::new(RefCell::new(None));
let preview_snapshot = Rc::new(RefCell::new(None));
let crop_overlay = Rc::new(RefCell::new(None));
let crop_overlay_failed = Rc::new(Cell::new(None));
let child = Rc::new(RefCell::new(None));
let area_preview = gtk::DrawingArea::builder()
.height_request(176)
.hexpand(true)
.build();
area_preview.add_css_class("ocula-area-preview");
area_preview.set_focusable(true);
area_preview.set_cursor_from_name(Some("pointer"));
area_preview.set_visible(false);
controls.append(&area_preview);
let audio_dropdown = gtk::DropDown::from_strings(&[
"No audio",
"Microphone",
"System audio",
"Microphone + system",
]);
audio_dropdown.set_hexpand(true);
controls.append(&setting_row("Audio", &audio_dropdown));
let hide_pointer_switch = gtk::Switch::builder().valign(gtk::Align::Center).build();
controls.append(&setting_row("Hide Pointer", &hide_pointer_switch));
let format_dropdown = gtk::DropDown::from_strings(&[
"MKV - fastest/safest",
"MP4 - compatible",
"GIF - short/no audio",
]);
format_dropdown.set_selected(1);
format_dropdown.set_hexpand(true);
controls.append(&setting_row("Format", &format_dropdown));
let encoder_dropdown =
gtk::DropDown::from_strings(&["Auto encoder", "Prefer GPU H.264", "Software H.264"]);
encoder_dropdown.set_hexpand(true);
controls.append(&setting_row("Encoder", &encoder_dropdown));
let effect_dropdown = gtk::DropDown::from_strings(&[
"No effect",
"Neon edges",
"Heatmap glow",
"Glitch prism",
"Water ripple",
"Glass refraction",
"CRT warp",
"Hologram",
"Vapor wave",
"Fracture glass",
"Prism glass",
"Aurora veil",
"Kaleidoscope",
]);
effect_dropdown.set_hexpand(true);
controls.append(&setting_row("GLSL Effect", &effect_dropdown));
install_area_preview_draw(
&area_preview,
Rc::clone(&selected_area),
Rc::clone(&preview_snapshot),
effect_dropdown.clone(),
);
install_area_preview_animation(&area_preview, Rc::clone(&child));
let output_box = gtk::Box::new(gtk::Orientation::Horizontal, 8);
let output_entry = gtk::Entry::builder()
.hexpand(true)
.placeholder_text(default_output_hint(OutputFormatChoice::Mp4))
.build();
let browse_button = gtk::Button::with_label("Choose...");
output_box.append(&output_entry);
output_box.append(&browse_button);
controls.append(&setting_row("Output", &output_box));
let record_button = gtk::Button::with_label("Start Recording");
record_button.add_css_class("suggested-action");
record_button.add_css_class("ocula-record-button");
root.append(&record_button);
let status_label = gtk::Label::new(Some("Ready"));
status_label.add_css_class("dim-label");
status_label.set_xalign(0.0);
root.append(&status_label);
let log_buffer = gtk::TextBuffer::new(None);
let log_view = gtk::TextView::builder()
.buffer(&log_buffer)
.editable(false)
.cursor_visible(false)
.monospace(true)
.wrap_mode(gtk::WrapMode::WordChar)
.build();
log_view.add_css_class("ocula-log");
let log_scroller = gtk::ScrolledWindow::builder()
.min_content_height(132)
.vexpand(true)
.child(&log_view)
.build();
root.append(&log_scroller);
let (log_tx, log_rx) = mpsc::sync_channel(LOG_CHANNEL_CAPACITY);
let ui = Ui {
window: window.clone(),
controls_card: controls.clone(),
screen_button,
window_button,
area_button,
audio_dropdown,
hide_pointer_switch,
area_row,
select_area_button,
area_label,
area_preview,
format_dropdown,
encoder_dropdown,
effect_dropdown,
output_entry,
record_button,
status_label,
log_buffer,
selected_area,
preview_snapshot,
crop_overlay,
crop_overlay_failed,
child,
stop_started_at: Rc::new(RefCell::new(None)),
force_stop_started_at: Rc::new(RefCell::new(None)),
kill_sent: Rc::new(Cell::new(false)),
close_after_child_exit: Rc::new(Cell::new(false)),
window_destroyed: Rc::new(Cell::new(false)),
log_tx,
log_rx: Rc::new(RefCell::new(log_rx)),
};
connect_mode_buttons(&ui);
connect_area_selector(&ui);
connect_area_preview_selector(&ui);
connect_format_picker(&ui);
connect_effect_picker(&ui);
connect_output_chooser(&ui, &browse_button);
connect_record_button(&ui);
connect_window_shutdown(&ui);
install_child_poll(&ui);
update_area_controls(&ui);
update_format_controls(&ui);
window.present();
}
fn install_css() {
let provider = gtk::CssProvider::new();
provider.load_from_string(
".ocula-window { background: linear-gradient(145deg, alpha(#020712, 0.96), alpha(@window_bg_color, 0.92)); }\n\
.ocula-shell { background: radial-gradient(circle at 12% 0%, alpha(#00e5ff, 0.18), transparent 32%), radial-gradient(circle at 92% 12%, alpha(#a855ff, 0.16), transparent 34%), linear-gradient(145deg, alpha(@accent_color, 0.08), alpha(@window_bg_color, 0.00) 42%, alpha(#7b2cff, 0.08)); }\n\
.ocula-hero { padding: 0 2px 4px 2px; }\n\
.ocula-card { padding: 17px; border-radius: 24px; background: linear-gradient(145deg, alpha(@card_bg_color, 0.72), alpha(#07111f, 0.42)); }\n\
.ocula-card { border: 1px solid alpha(#7df9ff, 0.26); box-shadow: inset 0 1px alpha(#ffffff, 0.08), 0 20px 54px rgba(0,0,0,0.30); }\n\
.ocula-card.ocula-area-ready { border-color: alpha(#00e0a4, 0.78); background: linear-gradient(145deg, alpha(#00e0a4, 0.12), alpha(@card_bg_color, 0.58)); }\n\
.ocula-card.ocula-recording { border-color: alpha(#ff4f58, 0.88); background: linear-gradient(145deg, alpha(#ff4f58, 0.17), alpha(@card_bg_color, 0.54)); }\n\
.ocula-section-label { font-weight: 820; letter-spacing: 0.075em; opacity: 0.90; font-size: 0.82em; }\n\
.ocula-setting-row { min-height: 38px; padding: 3px 0; }\n\
.ocula-setting-label { font-weight: 660; opacity: 0.88; }\n\
.ocula-area-row { padding: 9px 11px; border-radius: 17px; background: linear-gradient(135deg, alpha(@view_bg_color, 0.54), alpha(#071827, 0.38)); border: 1px solid alpha(#7df9ff, 0.24); box-shadow: inset 0 1px alpha(#ffffff, 0.06); }\n\
.ocula-area-select-button { padding: 8px 15px; border-radius: 999px; font-weight: 800; letter-spacing: 0.018em; background: linear-gradient(135deg, alpha(#00e5ff, 0.34), alpha(#7b2cff, 0.22)); border: 1px solid alpha(#7df9ff, 0.44); box-shadow: 0 9px 24px alpha(#00e5ff, 0.18); }\n\
.ocula-area-select-button:hover { border-color: alpha(#ffffff, 0.52); box-shadow: 0 12px 30px alpha(#00e5ff, 0.22); }\n\
.ocula-area-select-button.ocula-crop-ready { background: linear-gradient(135deg, alpha(#00e0a4, 0.38), alpha(#00e5ff, 0.16)); border-color: alpha(#00e0a4, 0.60); box-shadow: 0 10px 26px alpha(#00e0a4, 0.20); }\n\
.ocula-record-button { padding: 15px; border-radius: 999px; font-weight: 850; letter-spacing: 0.02em; box-shadow: 0 14px 34px alpha(@accent_color, 0.26); }\n\
.ocula-record-button.ocula-recording { background: linear-gradient(135deg, #ff4f58, #ff7a45); color: white; box-shadow: 0 14px 36px alpha(#ff4f58, 0.38); }\n\
.ocula-area-selected { color: #00e0a4; font-weight: 820; }\n\
.ocula-area-preview { border-radius: 22px; background: radial-gradient(circle at 20% 0%, alpha(#00e5ff, 0.18), transparent 36%), radial-gradient(circle at 100% 100%, alpha(#ff2bd6, 0.16), transparent 38%), linear-gradient(135deg, alpha(#050b16, 0.94), alpha(#10152b, 0.82), alpha(#250c32, 0.76)); border: 1px solid alpha(#7df9ff, 0.36); box-shadow: inset 0 1px alpha(#ffffff, 0.16), inset 0 -1px alpha(#000000, 0.28), 0 16px 38px rgba(0,0,0,0.30); }\n\
.ocula-area-preview:focus { border-color: alpha(#ffffff, 0.58); box-shadow: inset 0 1px alpha(#ffffff, 0.18), 0 0 0 2px alpha(#00e5ff, 0.24), 0 18px 44px rgba(0,0,0,0.34); }\n\
.ocula-mode-button { padding: 9px 13px; font-weight: 780; border-radius: 999px; }\n\
.ocula-mode-button:checked { background: linear-gradient(135deg, alpha(#00e5ff, 0.34), alpha(#7b2cff, 0.28)); color: white; box-shadow: inset 0 1px alpha(#ffffff, 0.12); }\n\
.ocula-card entry, .ocula-card dropdown button { border-radius: 13px; background: alpha(#07111f, 0.38); border: 1px solid alpha(#7df9ff, 0.16); }\n\
.ocula-log { padding: 10px; font-size: 12px; background: linear-gradient(145deg, alpha(#050a12, 0.70), alpha(@view_bg_color, 0.50)); border-radius: 16px; border: 1px solid alpha(#7df9ff, 0.14); box-shadow: inset 0 1px alpha(#ffffff, 0.05); }",
);
if let Some(display) = gtk::gdk::Display::default() {
gtk::style_context_add_provider_for_display(
&display,
&provider,
gtk::STYLE_PROVIDER_PRIORITY_APPLICATION,
);
}
}
fn card() -> gtk::Box {
let card = gtk::Box::new(gtk::Orientation::Vertical, 10);
card.add_css_class("ocula-card");
card
}
fn section_label(text: &str) -> gtk::Label {
let label = gtk::Label::new(Some(text));
label.set_xalign(0.0);
label.add_css_class("ocula-section-label");
label
}
fn mode_button(text: &str) -> gtk::ToggleButton {
let button = gtk::ToggleButton::with_label(text);
button.set_hexpand(true);
button.add_css_class("ocula-mode-button");
button
}
fn setting_row(label: &str, child: &impl IsA<gtk::Widget>) -> gtk::Box {
let row = gtk::Box::new(gtk::Orientation::Horizontal, 12);
row.set_valign(gtk::Align::Center);
row.add_css_class("ocula-setting-row");
let label = gtk::Label::new(Some(label));
label.set_xalign(0.0);
label.set_hexpand(true);
label.add_css_class("ocula-setting-label");
row.append(&label);
row.append(child);
row
}
fn install_area_preview_draw(
preview: >k::DrawingArea,
selected_area: Rc<RefCell<Option<SelectedArea>>>,
preview_snapshot: Rc<RefCell<Option<PreviewSnapshot>>>,
effect_dropdown: gtk::DropDown,
) {
let started_at = Instant::now();
preview.set_draw_func(move |_, cr, width, height| {
let selected = *selected_area.borrow();
let snapshot = preview_snapshot.borrow();
draw_area_preview(
cr,
width,
height,
selected,
snapshot.as_ref(),
effect_dropdown.selected(),
started_at.elapsed().as_secs_f64(),
);
});
}
fn install_area_preview_animation(preview: >k::DrawingArea, child: Rc<RefCell<Option<Child>>>) {
preview.add_tick_callback(move |preview, _| {
if preview.is_visible() && preview.is_mapped() && child.borrow().is_none() {
preview.queue_draw();
}
glib::ControlFlow::Continue
});
}
fn draw_area_preview(
cr: >k::cairo::Context,
width: i32,
height: i32,
selected: Option<SelectedArea>,
snapshot: Option<&PreviewSnapshot>,
effect: u32,
phase: f64,
) {
let width = f64::from(width.max(1));
let height = f64::from(height.max(1));
let style = preview_effect_style(effect);
let bg = gtk::cairo::LinearGradient::new(0.0, 0.0, width, height);
bg.add_color_stop_rgba(0.0, 0.02, 0.04, 0.08, 0.96);
bg.add_color_stop_rgba(
0.52,
style.primary.0 * 0.12,
style.primary.1 * 0.12,
(style.primary.2 * 0.26).max(0.10),
0.90,
);
bg.add_color_stop_rgba(
1.0,
style.secondary.0 * 0.18,
style.secondary.1 * 0.08,
style.secondary.2 * 0.18,
0.94,
);
cr.rectangle(0.0, 0.0, width, height);
let _ = cr.set_source(&bg);
let _ = cr.fill();
draw_preview_grid(cr, width, height, phase, style);
draw_preview_energy_orbs(cr, width, height, phase, style);
draw_preview_stage_marks(cr, width, height, phase, style);
let Some(selected) = selected else {
draw_preview_empty_state(cr, width, height, phase, style);
return;
};
let has_snapshot = snapshot.is_some();
let Some(rect) = snapshot
.and_then(|snapshot| preview_crop_rect(snapshot.width, snapshot.height, width, height))
.or_else(|| preview_selection_rect(selected, width, height))
else {
return;
};
draw_preview_aura(cr, rect, phase, style);
draw_preview_card(cr, rect, phase, style);
if let Some(snapshot) = snapshot {
draw_preview_snapshot(cr, rect, snapshot);
}
if !has_snapshot || effect != 0 {
draw_preview_effect_fill(cr, rect, effect, phase, style);
}
draw_preview_shader_overlay(cr, rect, effect, phase, style);
draw_preview_border(cr, rect, phase, style);
draw_preview_locator(cr, selected, width, height, style);
draw_preview_chip(
cr,
14.0,
14.0,
&format!(
"{}x{} at {},{}",
selected.area.width, selected.area.height, selected.area.x, selected.area.y
),
style.primary,
);
let effect_label = preview_effect_chip_text(style);
if width > 330.0 {
draw_preview_chip(
cr,
width - preview_chip_width(effect_label) - 14.0,
14.0,
effect_label,
style.secondary,
);
}
let hint = if has_snapshot {
"LIVE CROP REFINE READY"
} else {
"CROP MAP SNAPSHOT UNAVAILABLE"
};
draw_preview_chip(cr, 14.0, height - 34.0, hint, style.tertiary);
}
fn preview_effect_style(effect: u32) -> PreviewEffectStyle {
match effect {
1 => PreviewEffectStyle {
chip: "FX NEON",
primary: (0.00, 0.92, 1.00),
secondary: (1.00, 0.10, 0.84),
tertiary: (0.00, 1.00, 0.68),
},
2 => PreviewEffectStyle {
chip: "FX HEATMAP",
primary: (1.00, 0.78, 0.06),
secondary: (1.00, 0.12, 0.00),
tertiary: (0.18, 0.56, 1.00),
},
3 => PreviewEffectStyle {
chip: "FX GLITCH",
primary: (0.05, 1.00, 0.82),
secondary: (1.00, 0.10, 0.42),
tertiary: (0.74, 0.90, 1.00),
},
4 => PreviewEffectStyle {
chip: "FX RIPPLE",
primary: (0.18, 0.72, 1.00),
secondary: (0.18, 1.00, 0.86),
tertiary: (0.72, 0.92, 1.00),
},
5 => PreviewEffectStyle {
chip: "FX GLASS",
primary: (0.78, 0.96, 1.00),
secondary: (0.58, 0.72, 1.00),
tertiary: (1.00, 1.00, 1.00),
},
6 => PreviewEffectStyle {
chip: "FX CRT",
primary: (0.18, 1.00, 0.42),
secondary: (0.00, 0.56, 0.24),
tertiary: (0.70, 1.00, 0.76),
},
7 => PreviewEffectStyle {
chip: "FX HOLOGRAM",
primary: (0.28, 0.94, 1.00),
secondary: (1.00, 0.22, 0.94),
tertiary: (0.84, 1.00, 1.00),
},
8 => PreviewEffectStyle {
chip: "FX VAPOR",
primary: (0.26, 0.88, 1.00),
secondary: (0.90, 0.08, 1.00),
tertiary: (1.00, 0.58, 0.20),
},
9 => PreviewEffectStyle {
chip: "FX FRACTURE",
primary: (0.76, 0.96, 1.00),
secondary: (1.00, 0.16, 0.52),
tertiary: (1.00, 1.00, 1.00),
},
10 => PreviewEffectStyle {
chip: "FX PRISM",
primary: (1.00, 0.12, 0.18),
secondary: (0.00, 0.82, 1.00),
tertiary: (1.00, 0.88, 0.04),
},
11 => PreviewEffectStyle {
chip: "FX AURORA",
primary: (0.06, 0.94, 0.54),
secondary: (0.14, 0.68, 1.00),
tertiary: (0.74, 0.10, 1.00),
},
12 => PreviewEffectStyle {
chip: "FX KALEIDO",
primary: (0.00, 0.76, 1.00),
secondary: (1.00, 0.18, 0.84),
tertiary: (0.18, 1.00, 0.54),
},
_ => PreviewEffectStyle {
chip: "FX OFF",
primary: (0.00, 0.88, 0.70),
secondary: (0.48, 0.78, 1.00),
tertiary: (0.72, 0.96, 1.00),
},
}
}
fn draw_preview_grid(
cr: >k::cairo::Context,
width: f64,
height: f64,
phase: f64,
style: PreviewEffectStyle,
) {
cr.set_line_width(1.0);
set_preview_color(cr, style.tertiary, 0.12);
let offset = (phase * 12.0) % 18.0;
let mut x = -offset;
while x < width {
cr.move_to(x, 0.0);
cr.line_to(x + height * 0.16, height);
x += 18.0;
}
let mut y = offset;
while y < height {
cr.move_to(0.0, y);
cr.line_to(width, y - width * 0.08);
y += 18.0;
}
let _ = cr.stroke();
}
fn draw_preview_energy_orbs(
cr: >k::cairo::Context,
width: f64,
height: f64,
phase: f64,
style: PreviewEffectStyle,
) {
let _ = cr.save();
let cx = width * (0.18 + 0.03 * phase.sin());
let cy = height * (0.20 + 0.04 * (phase * 0.7).cos());
let orb = gtk::cairo::RadialGradient::new(cx, cy, 4.0, cx, cy, width.max(height) * 0.42);
orb.add_color_stop_rgba(
0.0,
style.secondary.0,
style.secondary.1,
style.secondary.2,
0.24,
);
orb.add_color_stop_rgba(1.0, 0.0, 0.0, 0.0, 0.0);
cr.rectangle(0.0, 0.0, width, height);
let _ = cr.set_source(&orb);
let _ = cr.fill();
let cx = width * (0.78 + 0.02 * (phase * 0.5).cos());
let cy = height * (0.72 + 0.03 * phase.sin());
let orb = gtk::cairo::RadialGradient::new(cx, cy, 3.0, cx, cy, width.max(height) * 0.36);
orb.add_color_stop_rgba(0.0, style.primary.0, style.primary.1, style.primary.2, 0.18);
orb.add_color_stop_rgba(1.0, 0.0, 0.0, 0.0, 0.0);
cr.rectangle(0.0, 0.0, width, height);
let _ = cr.set_source(&orb);
let _ = cr.fill();
let _ = cr.restore();
}
fn draw_preview_stage_marks(
cr: >k::cairo::Context,
width: f64,
height: f64,
phase: f64,
style: PreviewEffectStyle,
) {
let _ = cr.save();
set_preview_color(cr, style.primary, 0.16);
cr.set_line_width(1.0);
let rail_y = height - 48.0;
cr.move_to(18.0, rail_y);
cr.line_to(width - 18.0, rail_y);
let _ = cr.stroke();
let pulse_x = 28.0 + ((phase * 54.0) % (width - 56.0).max(1.0));
let sweep = gtk::cairo::LinearGradient::new(pulse_x - 36.0, rail_y, pulse_x + 36.0, rail_y);
sweep.add_color_stop_rgba(0.0, style.primary.0, style.primary.1, style.primary.2, 0.0);
sweep.add_color_stop_rgba(0.5, style.primary.0, style.primary.1, style.primary.2, 0.32);
sweep.add_color_stop_rgba(1.0, style.primary.0, style.primary.1, style.primary.2, 0.0);
cr.rectangle(pulse_x - 36.0, rail_y - 1.0, 72.0, 2.0);
let _ = cr.set_source(&sweep);
let _ = cr.fill();
for i in 0..7 {
let x = 22.0 + f64::from(i) * 22.0;
set_preview_color(cr, style.tertiary, 0.20);
cr.rectangle(x, 15.0, 10.0, 1.0);
let _ = cr.fill();
}
let _ = cr.restore();
}
fn draw_preview_empty_state(
cr: >k::cairo::Context,
width: f64,
height: f64,
phase: f64,
style: PreviewEffectStyle,
) {
let rect = PreviewRect {
x: width * 0.12,
y: height * 0.28,
width: width * 0.76,
height: height * 0.44,
};
draw_preview_aura(cr, rect, phase, style);
draw_preview_card(cr, rect, phase, style);
draw_preview_shader_overlay(cr, rect, 7, phase, style);
draw_preview_border(cr, rect, phase, style);
draw_preview_chip(
cr,
rect.x + 14.0,
rect.y + rect.height / 2.0 - 12.0,
"CLICK OR ENTER TO PICK CROP",
style.tertiary,
);
draw_preview_chip(cr, 14.0, 14.0, "AREA MODE", style.primary);
let effect_label = preview_effect_chip_text(style);
if width > 330.0 {
draw_preview_chip(
cr,
width - preview_chip_width(effect_label) - 14.0,
14.0,
effect_label,
style.secondary,
);
}
}
fn draw_preview_effect_fill(
cr: >k::cairo::Context,
rect: PreviewRect,
effect: u32,
phase: f64,
style: PreviewEffectStyle,
) {
let _ = cr.save();
rounded_rectangle(cr, rect, 14.0);
cr.clip();
let gradient = gtk::cairo::LinearGradient::new(rect.x, rect.y, rect.right(), rect.bottom());
match effect {
1 => {
gradient.add_color_stop_rgba(0.0, 0.00, 0.90, 1.00, 0.42);
gradient.add_color_stop_rgba(1.0, 1.00, 0.00, 0.80, 0.42);
}
2 => {
gradient.add_color_stop_rgba(0.0, 0.05, 0.18, 0.95, 0.42);
gradient.add_color_stop_rgba(0.5, 1.00, 0.88, 0.05, 0.38);
gradient.add_color_stop_rgba(1.0, 1.00, 0.10, 0.00, 0.42);
}
3 => {
gradient.add_color_stop_rgba(0.0, 0.15, 1.00, 0.90, 0.38);
gradient.add_color_stop_rgba(0.48, 1.00, 1.00, 1.00, 0.22);
gradient.add_color_stop_rgba(1.0, 1.00, 0.08, 0.35, 0.40);
}
4 => {
gradient.add_color_stop_rgba(0.0, 0.02, 0.35, 0.75, 0.44);
gradient.add_color_stop_rgba(0.5, 0.22, 0.95, 1.00, 0.28);
gradient.add_color_stop_rgba(1.0, 0.03, 0.10, 0.24, 0.48);
}
5 => {
gradient.add_color_stop_rgba(0.0, 0.82, 0.95, 1.00, 0.36);
gradient.add_color_stop_rgba(0.5, 1.00, 1.00, 1.00, 0.20);
gradient.add_color_stop_rgba(1.0, 0.60, 0.72, 1.00, 0.32);
}
6 => {
gradient.add_color_stop_rgba(0.0, 0.05, 0.45, 0.20, 0.44);
gradient.add_color_stop_rgba(1.0, 0.04, 0.95, 0.58, 0.30);
}
7 => {
gradient.add_color_stop_rgba(0.0, 0.00, 0.90, 1.00, 0.36);
gradient.add_color_stop_rgba(0.5, 1.00, 0.12, 0.95, 0.28);
gradient.add_color_stop_rgba(1.0, 0.85, 1.00, 1.00, 0.30);
}
8 => {
gradient.add_color_stop_rgba(0.0, 0.08, 0.02, 0.22, 0.42);
gradient.add_color_stop_rgba(0.5, 0.28, 0.95, 1.00, 0.30);
gradient.add_color_stop_rgba(1.0, 0.70, 0.00, 0.88, 0.36);
}
9 => {
gradient.add_color_stop_rgba(0.0, 0.72, 0.96, 1.00, 0.30);
gradient.add_color_stop_rgba(0.5, 1.00, 1.00, 1.00, 0.22);
gradient.add_color_stop_rgba(1.0, 1.00, 0.10, 0.55, 0.34);
}
10 => {
gradient.add_color_stop_rgba(0.0, 1.00, 0.10, 0.16, 0.28);
gradient.add_color_stop_rgba(0.33, 1.00, 0.82, 0.02, 0.24);
gradient.add_color_stop_rgba(0.66, 0.00, 0.82, 1.00, 0.28);
gradient.add_color_stop_rgba(1.0, 0.90, 0.12, 1.00, 0.30);
}
11 => {
gradient.add_color_stop_rgba(0.0, 0.04, 0.90, 0.42, 0.32);
gradient.add_color_stop_rgba(0.45, 0.10, 0.86, 1.00, 0.24);
gradient.add_color_stop_rgba(1.0, 0.62, 0.05, 1.00, 0.30);
}
12 => {
gradient.add_color_stop_rgba(0.0, 0.00, 0.72, 1.00, 0.30);
gradient.add_color_stop_rgba(0.50, 1.00, 0.20, 0.88, 0.26);
gradient.add_color_stop_rgba(1.0, 0.18, 1.00, 0.50, 0.30);
}
_ => {
gradient.add_color_stop_rgba(
0.0,
style.primary.0,
style.primary.1,
style.primary.2,
0.28,
);
gradient.add_color_stop_rgba(
0.54,
style.tertiary.0,
style.tertiary.1,
style.tertiary.2,
0.16,
);
gradient.add_color_stop_rgba(
1.0,
style.secondary.0,
style.secondary.1,
style.secondary.2,
0.28,
);
}
}
cr.rectangle(rect.x, rect.y, rect.width, rect.height);
let _ = cr.set_source(&gradient);
let _ = cr.fill();
cr.set_source_rgba(1.0, 1.0, 1.0, 0.18);
cr.set_line_width(1.0);
let mut y = rect.y + ((phase * 32.0) % 11.0);
while y < rect.bottom() {
cr.move_to(rect.x, y);
cr.line_to(rect.right(), y + (phase * 2.0).sin() * 2.0);
y += 11.0;
}
let _ = cr.stroke();
let _ = cr.restore();
}
fn draw_preview_shader_overlay(
cr: >k::cairo::Context,
rect: PreviewRect,
effect: u32,
phase: f64,
style: PreviewEffectStyle,
) {
let _ = cr.save();
rounded_rectangle(cr, rect, 14.0);
cr.clip();
let shimmer = gtk::cairo::LinearGradient::new(rect.x, rect.y, rect.right(), rect.bottom());
shimmer.add_color_stop_rgba(0.0, 1.0, 1.0, 1.0, 0.16);
shimmer.add_color_stop_rgba(
0.34,
style.primary.0,
style.primary.1,
style.primary.2,
0.07,
);
shimmer.add_color_stop_rgba(
0.66,
style.secondary.0,
style.secondary.1,
style.secondary.2,
0.09,
);
shimmer.add_color_stop_rgba(1.0, 1.0, 1.0, 1.0, 0.03);
cr.rectangle(rect.x, rect.y, rect.width, rect.height);
let _ = cr.set_source(&shimmer);
let _ = cr.fill();
cr.set_line_width(2.0);
for i in 0..5 {
let t = f64::from(i) / 5.0;
let drift = ((phase * 0.55) + f64::from(i) * 0.37).sin() * 12.0;
let x = rect.x + rect.width * t + drift;
match (effect + i) % 3 {
0 => set_preview_color(cr, style.primary, 0.16),
1 => set_preview_color(cr, style.secondary, 0.13),
_ => set_preview_color(cr, style.tertiary, 0.14),
}
cr.move_to(x, rect.y - 12.0);
cr.line_to(x + rect.height * 0.32, rect.bottom() + 12.0);
let _ = cr.stroke();
}
cr.set_line_width(1.0);
cr.set_source_rgba(1.0, 1.0, 1.0, 0.10);
let mut y = rect.y + ((phase * 28.0) % 8.0);
while y < rect.bottom() {
cr.move_to(rect.x, y);
cr.line_to(rect.right(), y + (phase + y * 0.03).sin());
y += 8.0;
}
let _ = cr.stroke();
draw_preview_spectral_sweep(cr, rect, effect, phase, style);
draw_preview_effect_signature(cr, rect, effect, phase, style);
let _ = cr.restore();
}
fn draw_preview_spectral_sweep(
cr: >k::cairo::Context,
rect: PreviewRect,
effect: u32,
phase: f64,
style: PreviewEffectStyle,
) {
let sweep = ((phase * 0.42 + f64::from(effect) * 0.17).sin() + 1.0) / 2.0;
let x = rect.x + rect.width * sweep;
let beam = gtk::cairo::LinearGradient::new(x - 42.0, rect.y, x + 42.0, rect.bottom());
beam.add_color_stop_rgba(0.0, style.primary.0, style.primary.1, style.primary.2, 0.0);
beam.add_color_stop_rgba(
0.48,
style.tertiary.0,
style.tertiary.1,
style.tertiary.2,
0.16,
);
beam.add_color_stop_rgba(
1.0,
style.secondary.0,
style.secondary.1,
style.secondary.2,
0.0,
);
cr.rectangle(x - 42.0, rect.y, 84.0, rect.height);
let _ = cr.set_source(&beam);
let _ = cr.fill();
}
fn draw_preview_effect_signature(
cr: >k::cairo::Context,
rect: PreviewRect,
effect: u32,
phase: f64,
style: PreviewEffectStyle,
) {
match effect {
1 => draw_preview_neon_signature(cr, rect, phase, style),
2 => draw_preview_heatmap_signature(cr, rect, phase, style),
3 | 10 | 12 => draw_preview_prism_signature(cr, rect, phase, style),
4 => draw_preview_ripple_signature(cr, rect, phase, style),
5 => draw_preview_glass_signature(cr, rect, phase, style),
6 => draw_preview_crt_signature(cr, rect, phase, style),
7 => draw_preview_holo_signature(cr, rect, phase, style),
8 => draw_preview_vapor_signature(cr, rect, phase, style),
9 => draw_preview_fracture_signature(cr, rect, phase, style),
11 => draw_preview_aurora_signature(cr, rect, phase, style),
_ => draw_preview_light_signature(cr, rect, phase, style),
}
}
fn draw_preview_neon_signature(
cr: >k::cairo::Context,
rect: PreviewRect,
phase: f64,
style: PreviewEffectStyle,
) {
cr.set_line_width(1.3);
for i in 0..6 {
let t = (f64::from(i) + 0.5) / 6.0;
let y = rect.y + rect.height * t;
let jog = (phase * 1.7 + f64::from(i)).sin() * rect.width * 0.08;
set_preview_color(
cr,
if i % 2 == 0 {
style.primary
} else {
style.secondary
},
0.28,
);
cr.move_to(rect.x + 12.0, y);
cr.line_to(rect.x + rect.width * 0.36 + jog, y);
cr.line_to(rect.x + rect.width * 0.46 + jog, y - 10.0);
cr.line_to(rect.right() - 16.0, y - 10.0);
let _ = cr.stroke();
cr.arc(
rect.x + rect.width * 0.46 + jog,
y - 10.0,
2.2,
0.0,
PI * 2.0,
);
let _ = cr.fill();
}
}
fn draw_preview_heatmap_signature(
cr: >k::cairo::Context,
rect: PreviewRect,
phase: f64,
style: PreviewEffectStyle,
) {
let _ = cr.save();
cr.translate(rect.x + rect.width * 0.52, rect.y + rect.height * 0.52);
cr.scale(1.45, 0.72);
cr.set_line_width(1.0);
for i in 0..6 {
let radius = rect.height * (0.13 + f64::from(i) * 0.09 + 0.015 * phase.sin());
let color = match i % 3 {
0 => style.primary,
1 => style.secondary,
_ => style.tertiary,
};
set_preview_color(cr, color, 0.11 + f64::from(6 - i) * 0.018);
cr.arc(0.0, 0.0, radius, 0.0, PI * 2.0);
let _ = cr.stroke();
}
let _ = cr.restore();
}
fn draw_preview_prism_signature(
cr: >k::cairo::Context,
rect: PreviewRect,
phase: f64,
style: PreviewEffectStyle,
) {
cr.set_line_width(1.4);
for i in 0..8 {
let t = f64::from(i) / 7.0;
let x = rect.x + rect.width * t;
let offset = (phase * 18.0 + f64::from(i) * 9.0).sin() * 5.0;
let color = match i % 3 {
0 => style.primary,
1 => style.secondary,
_ => style.tertiary,
};
set_preview_color(cr, color, 0.22);
cr.move_to(x + offset, rect.y + 8.0);
cr.line_to(
rect.x + rect.width * (1.0 - t * 0.08) + offset,
rect.bottom() - 8.0,
);
}
let _ = cr.stroke();
}
fn draw_preview_ripple_signature(
cr: >k::cairo::Context,
rect: PreviewRect,
phase: f64,
style: PreviewEffectStyle,
) {
cr.set_line_width(1.2);
for i in 0..5 {
let y = rect.y + rect.height * (f64::from(i) + 0.5) / 5.0;
set_preview_color(cr, style.primary, 0.20);
let mut x = rect.x;
cr.move_to(x, y);
while x <= rect.right() {
let wave = (phase * 2.0 + x * 0.035 + f64::from(i)).sin() * 4.0;
cr.line_to(x, y + wave);
x += 10.0;
}
}
let _ = cr.stroke();
}
fn draw_preview_glass_signature(
cr: >k::cairo::Context,
rect: PreviewRect,
phase: f64,
style: PreviewEffectStyle,
) {
cr.set_line_width(1.0);
for i in 0..9 {
let t = f64::from(i) / 8.0;
let x = rect.x + rect.width * t;
let bend = (phase * 0.9 + f64::from(i) * 0.7).sin() * 10.0;
set_preview_color(
cr,
if i % 2 == 0 {
style.tertiary
} else {
style.primary
},
0.18,
);
cr.move_to(x, rect.y + 6.0);
cr.line_to(x + bend, rect.y + rect.height * 0.44);
cr.line_to(x - bend * 0.7, rect.bottom() - 6.0);
}
for i in 0..4 {
let y = rect.y + rect.height * (f64::from(i) + 1.0) / 5.0;
set_preview_color(cr, style.secondary, 0.13);
cr.move_to(rect.x + 8.0, y);
cr.line_to(rect.right() - 8.0, y + (phase + f64::from(i)).cos() * 6.0);
}
let _ = cr.stroke();
}
fn draw_preview_crt_signature(
cr: >k::cairo::Context,
rect: PreviewRect,
phase: f64,
style: PreviewEffectStyle,
) {
set_preview_color(cr, style.primary, 0.18);
let mut y = rect.y + ((phase * 18.0) % 4.0);
while y < rect.bottom() {
cr.rectangle(rect.x, y, rect.width, 1.0);
y += 4.0;
}
let _ = cr.fill();
}
fn draw_preview_holo_signature(
cr: >k::cairo::Context,
rect: PreviewRect,
phase: f64,
style: PreviewEffectStyle,
) {
let mut y = rect.y + ((phase * 34.0) % 10.0);
while y < rect.bottom() {
let shimmer = 0.08 + 0.08 * (phase + y * 0.05).sin().abs();
set_preview_color(cr, style.primary, shimmer);
cr.rectangle(rect.x, y, rect.width, 2.0);
let _ = cr.fill();
y += 10.0;
}
cr.set_line_width(1.2);
for i in 0..5 {
let x = rect.x + rect.width * (f64::from(i) + 0.3) / 5.4;
set_preview_color(cr, style.secondary, 0.18);
cr.move_to(x, rect.y + 8.0);
cr.line_to(x + rect.height * 0.40, rect.bottom() - 8.0);
}
let _ = cr.stroke();
}
fn draw_preview_vapor_signature(
cr: >k::cairo::Context,
rect: PreviewRect,
phase: f64,
style: PreviewEffectStyle,
) {
cr.set_line_width(1.2);
let cx = rect.x + rect.width * 0.52;
let cy = rect.y + rect.height * 0.52;
for arm in 0..3 {
let offset = f64::from(arm) * PI * 2.0 / 3.0;
set_preview_color(
cr,
match arm {
0 => style.primary,
1 => style.secondary,
_ => style.tertiary,
},
0.20,
);
for step in 0..34 {
let t = f64::from(step) / 33.0;
let radius = rect.height * 0.08 + rect.width.min(rect.height) * 0.44 * t;
let angle = offset + t * PI * 2.2 + phase * 0.45;
let x = cx + angle.cos() * radius;
let y = cy + angle.sin() * radius * 0.62;
if step == 0 {
cr.move_to(x, y);
} else {
cr.line_to(x, y);
}
}
let _ = cr.stroke();
}
}
fn draw_preview_fracture_signature(
cr: >k::cairo::Context,
rect: PreviewRect,
phase: f64,
style: PreviewEffectStyle,
) {
cr.set_line_width(1.0);
for i in 0..7 {
let anchor = rect.x + rect.width * (f64::from(i) + 1.0) / 8.0;
let tilt = (phase + f64::from(i) * 0.9).sin() * 18.0;
set_preview_color(
cr,
if i % 2 == 0 {
style.tertiary
} else {
style.secondary
},
0.24,
);
cr.move_to(anchor, rect.y + 7.0);
cr.line_to(anchor + tilt, rect.y + rect.height * 0.52);
cr.line_to(anchor - tilt * 0.4, rect.bottom() - 7.0);
}
let _ = cr.stroke();
}
fn draw_preview_aurora_signature(
cr: >k::cairo::Context,
rect: PreviewRect,
phase: f64,
style: PreviewEffectStyle,
) {
let band = gtk::cairo::LinearGradient::new(rect.x, rect.y, rect.right(), rect.bottom());
band.add_color_stop_rgba(0.0, style.primary.0, style.primary.1, style.primary.2, 0.00);
band.add_color_stop_rgba(
0.42,
style.primary.0,
style.primary.1,
style.primary.2,
0.18,
);
band.add_color_stop_rgba(
0.70,
style.tertiary.0,
style.tertiary.1,
style.tertiary.2,
0.15,
);
band.add_color_stop_rgba(
1.0,
style.secondary.0,
style.secondary.1,
style.secondary.2,
0.00,
);
cr.rectangle(rect.x, rect.y + phase.sin() * 4.0, rect.width, rect.height);
let _ = cr.set_source(&band);
let _ = cr.fill();
}
fn draw_preview_light_signature(
cr: >k::cairo::Context,
rect: PreviewRect,
phase: f64,
style: PreviewEffectStyle,
) {
let glint_x = rect.x + rect.width * (0.18 + 0.64 * ((phase * 0.32).sin() + 1.0) / 2.0);
let glint = gtk::cairo::LinearGradient::new(glint_x - 28.0, rect.y, glint_x + 28.0, rect.y);
glint.add_color_stop_rgba(
0.0,
style.tertiary.0,
style.tertiary.1,
style.tertiary.2,
0.0,
);
glint.add_color_stop_rgba(
0.5,
style.tertiary.0,
style.tertiary.1,
style.tertiary.2,
0.22,
);
glint.add_color_stop_rgba(
1.0,
style.tertiary.0,
style.tertiary.1,
style.tertiary.2,
0.0,
);
cr.rectangle(glint_x - 28.0, rect.y, 56.0, rect.height);
let _ = cr.set_source(&glint);
let _ = cr.fill();
}
fn draw_preview_snapshot(cr: >k::cairo::Context, rect: PreviewRect, snapshot: &PreviewSnapshot) {
let _ = cr.save();
rounded_rectangle(cr, rect, 14.0);
cr.clip();
cr.translate(rect.x, rect.y);
cr.scale(
rect.width / f64::from(snapshot.width.max(1)),
rect.height / f64::from(snapshot.height.max(1)),
);
let _ = cr.set_source_surface(&snapshot.surface, 0.0, 0.0);
let _ = cr.paint();
let _ = cr.restore();
}
fn draw_preview_aura(
cr: >k::cairo::Context,
rect: PreviewRect,
phase: f64,
style: PreviewEffectStyle,
) {
let _ = cr.save();
let cx = rect.x + rect.width / 2.0;
let cy = rect.y + rect.height / 2.0;
let radius = rect.width.max(rect.height) * (0.72 + 0.04 * phase.sin().abs());
let aura = gtk::cairo::RadialGradient::new(cx, cy, 1.0, cx, cy, radius);
aura.add_color_stop_rgba(0.0, style.primary.0, style.primary.1, style.primary.2, 0.24);
aura.add_color_stop_rgba(
0.55,
style.secondary.0,
style.secondary.1,
style.secondary.2,
0.09,
);
aura.add_color_stop_rgba(1.0, 0.0, 0.0, 0.0, 0.0);
cr.rectangle(
rect.x - 28.0,
rect.y - 28.0,
rect.width + 56.0,
rect.height + 56.0,
);
let _ = cr.set_source(&aura);
let _ = cr.fill();
let _ = cr.restore();
}
fn draw_preview_card(
cr: >k::cairo::Context,
rect: PreviewRect,
phase: f64,
style: PreviewEffectStyle,
) {
let _ = cr.save();
rounded_rectangle(
cr,
PreviewRect {
x: rect.x - 9.0,
y: rect.y + 10.0,
width: rect.width + 18.0,
height: rect.height + 18.0,
},
17.0,
);
cr.set_source_rgba(0.0, 0.0, 0.0, 0.40);
let _ = cr.fill();
let glass = gtk::cairo::LinearGradient::new(rect.x, rect.y, rect.right(), rect.bottom());
glass.add_color_stop_rgba(0.0, 1.0, 1.0, 1.0, 0.20);
glass.add_color_stop_rgba(
0.24,
style.tertiary.0,
style.tertiary.1,
style.tertiary.2,
0.10,
);
glass.add_color_stop_rgba(
0.72,
style.secondary.0 * 0.22,
style.secondary.1 * 0.22,
style.secondary.2 * 0.34,
0.18,
);
glass.add_color_stop_rgba(1.0, 0.02, 0.05, 0.12, 0.50);
rounded_rectangle(cr, rect, 14.0);
let _ = cr.set_source(&glass);
let _ = cr.fill();
let sheen = gtk::cairo::LinearGradient::new(rect.x, rect.y, rect.right(), rect.y);
sheen.add_color_stop_rgba(0.0, 1.0, 1.0, 1.0, 0.02);
sheen.add_color_stop_rgba(0.50, 1.0, 1.0, 1.0, 0.18 + 0.04 * phase.sin().abs());
sheen.add_color_stop_rgba(1.0, 1.0, 1.0, 1.0, 0.02);
rounded_rectangle(cr, rect.inset(4.0), 10.0);
let _ = cr.set_source(&sheen);
let _ = cr.fill();
let _ = cr.restore();
}
fn draw_preview_border(
cr: >k::cairo::Context,
rect: PreviewRect,
phase: f64,
style: PreviewEffectStyle,
) {
rounded_rectangle(cr, rect, 14.0);
cr.set_line_width(2.4);
set_preview_color(cr, style.tertiary, 0.78);
let _ = cr.stroke();
rounded_rectangle(
cr,
PreviewRect {
x: rect.x - 4.0,
y: rect.y - 4.0,
width: rect.width + 8.0,
height: rect.height + 8.0,
},
17.0,
);
cr.set_line_width(1.0 + phase.sin().abs() * 1.8);
set_preview_color(cr, style.secondary, 0.50);
let _ = cr.stroke();
rounded_rectangle(cr, rect.inset(4.0), 10.0);
cr.set_line_width(1.0);
set_preview_color(cr, style.primary, 0.42);
let _ = cr.stroke();
draw_preview_corner_brackets(cr, rect, phase, style);
}
fn draw_preview_locator(
cr: >k::cairo::Context,
selected: SelectedArea,
width: f64,
height: f64,
style: PreviewEffectStyle,
) {
if selected.screen_width <= 0 || selected.screen_height <= 0 || width < 120.0 || height < 86.0 {
return;
}
let max_width = (width * 0.24).clamp(68.0, 92.0);
let max_height = 46.0;
let scale = (max_width / f64::from(selected.screen_width))
.min(max_height / f64::from(selected.screen_height));
let map_width = f64::from(selected.screen_width) * scale;
let map_height = f64::from(selected.screen_height) * scale;
let x = width - map_width - 14.0;
let y = height - map_height - 14.0;
let _ = cr.save();
rounded_rectangle(
cr,
PreviewRect {
x: x - 5.0,
y: y - 5.0,
width: map_width + 10.0,
height: map_height + 10.0,
},
7.0,
);
cr.set_source_rgba(0.01, 0.03, 0.06, 0.58);
let _ = cr.fill();
cr.rectangle(x, y, map_width, map_height);
cr.set_source_rgba(1.0, 1.0, 1.0, 0.16);
let _ = cr.fill();
cr.rectangle(x, y, map_width, map_height);
cr.set_line_width(1.0);
set_preview_color(cr, style.tertiary, 0.62);
let _ = cr.stroke();
cr.rectangle(
x + f64::from(selected.area.x) * scale,
y + f64::from(selected.area.y) * scale,
f64::from(selected.area.width).max(1.0) * scale,
f64::from(selected.area.height).max(1.0) * scale,
);
set_preview_color(cr, style.primary, 0.42);
let _ = cr.fill_preserve();
set_preview_color(cr, style.primary, 0.94);
let _ = cr.stroke();
let _ = cr.restore();
}
fn draw_preview_text(cr: >k::cairo::Context, x: f64, y: f64, text: &str) {
cr.select_font_face(
"Sans",
gtk::cairo::FontSlant::Normal,
gtk::cairo::FontWeight::Bold,
);
cr.set_font_size(12.0);
cr.set_source_rgba(0.0, 0.0, 0.0, 0.48);
cr.move_to(x + 1.0, y + 1.0);
let _ = cr.show_text(text);
cr.set_source_rgba(1.0, 1.0, 1.0, 0.88);
cr.move_to(x, y);
let _ = cr.show_text(text);
}
fn draw_preview_corner_brackets(
cr: >k::cairo::Context,
rect: PreviewRect,
phase: f64,
style: PreviewEffectStyle,
) {
let _ = cr.save();
let length = rect.width.min(rect.height).clamp(18.0, 34.0);
let inset = 6.0;
let pulse = 0.56 + 0.18 * phase.sin().abs();
cr.set_line_width(2.6);
set_preview_color(cr, style.primary, pulse);
for (x, y, sx, sy) in [
(rect.x + inset, rect.y + inset, 1.0, 1.0),
(rect.right() - inset, rect.y + inset, -1.0, 1.0),
(rect.right() - inset, rect.bottom() - inset, -1.0, -1.0),
(rect.x + inset, rect.bottom() - inset, 1.0, -1.0),
] {
cr.move_to(x, y + sy * length);
cr.line_to(x, y);
cr.line_to(x + sx * length, y);
}
let _ = cr.stroke();
cr.set_line_width(1.0);
set_preview_color(cr, style.tertiary, 0.44);
for i in 0..4 {
let t = f64::from(i) / 3.0;
let x = rect.x + rect.width * t;
cr.move_to(x, rect.y - 6.0);
cr.line_to(x, rect.y - 2.0);
cr.move_to(x, rect.bottom() + 2.0);
cr.line_to(x, rect.bottom() + 6.0);
}
let _ = cr.stroke();
let _ = cr.restore();
}
fn draw_preview_chip(
cr: >k::cairo::Context,
x: f64,
y: f64,
text: &str,
accent: (f64, f64, f64),
) {
let width = preview_chip_width(text);
let height = 23.0;
rounded_rectangle(
cr,
PreviewRect {
x,
y,
width,
height,
},
8.0,
);
cr.set_source_rgba(0.02, 0.04, 0.08, 0.66);
let _ = cr.fill_preserve();
cr.set_line_width(1.0);
set_preview_color(cr, accent, 0.58);
let _ = cr.stroke();
rounded_rectangle(
cr,
PreviewRect {
x: x + 5.0,
y: y + 5.0,
width: 4.0,
height: height - 10.0,
},
2.0,
);
set_preview_color(cr, accent, 0.88);
let _ = cr.fill();
draw_preview_text(cr, x + 14.0, y + 16.0, text);
}
fn preview_chip_width(text: &str) -> f64 {
(text.len() as f64 * 7.0 + 22.0).max(72.0)
}
fn preview_effect_chip_text(style: PreviewEffectStyle) -> &'static str {
style.chip
}
fn set_preview_color(cr: >k::cairo::Context, color: (f64, f64, f64), alpha: f64) {
cr.set_source_rgba(color.0, color.1, color.2, alpha);
}
fn rounded_rectangle(cr: >k::cairo::Context, rect: PreviewRect, radius: f64) {
let radius = radius.min(rect.width / 2.0).min(rect.height / 2.0).max(0.0);
cr.new_sub_path();
cr.arc(
rect.right() - radius,
rect.y + radius,
radius,
-FRAC_PI_2,
0.0,
);
cr.arc(
rect.right() - radius,
rect.bottom() - radius,
radius,
0.0,
FRAC_PI_2,
);
cr.arc(
rect.x + radius,
rect.bottom() - radius,
radius,
FRAC_PI_2,
PI,
);
cr.arc(rect.x + radius, rect.y + radius, radius, PI, PI + FRAC_PI_2);
cr.close_path();
}
#[derive(Debug, Clone, Copy, PartialEq)]
struct PreviewRect {
x: f64,
y: f64,
width: f64,
height: f64,
}
impl PreviewRect {
fn right(self) -> f64 {
self.x + self.width
}
fn bottom(self) -> f64 {
self.y + self.height
}
fn inset(self, amount: f64) -> Self {
let amount = amount.max(0.0).min(self.width / 2.0).min(self.height / 2.0);
Self {
x: self.x + amount,
y: self.y + amount,
width: (self.width - amount * 2.0).max(1.0),
height: (self.height - amount * 2.0).max(1.0),
}
}
}
fn preview_selection_rect(selected: SelectedArea, width: f64, height: f64) -> Option<PreviewRect> {
if selected.screen_width <= 0 || selected.screen_height <= 0 || width <= 0.0 || height <= 0.0 {
return None;
}
let margin = 14.0;
let available_width = (width - margin * 2.0).max(1.0);
let available_height = (height - margin * 2.0).max(1.0);
let scale = (available_width / f64::from(selected.screen_width))
.min(available_height / f64::from(selected.screen_height));
let screen_width = f64::from(selected.screen_width) * scale;
let screen_height = f64::from(selected.screen_height) * scale;
let origin_x = (width - screen_width) / 2.0;
let origin_y = (height - screen_height) / 2.0;
Some(PreviewRect {
x: origin_x + f64::from(selected.area.x) * scale,
y: origin_y + f64::from(selected.area.y) * scale,
width: f64::from(selected.area.width) * scale,
height: f64::from(selected.area.height) * scale,
})
}
fn preview_crop_rect(
content_width: i32,
content_height: i32,
width: f64,
height: f64,
) -> Option<PreviewRect> {
if content_width <= 0 || content_height <= 0 || width <= 0.0 || height <= 0.0 {
return None;
}
let margin = 16.0;
let available_width = (width - margin * 2.0).max(1.0);
let available_height = (height - margin * 2.0).max(1.0);
let scale = (available_width / f64::from(content_width))
.min(available_height / f64::from(content_height));
let crop_width = f64::from(content_width) * scale;
let crop_height = f64::from(content_height) * scale;
Some(PreviewRect {
x: (width - crop_width) / 2.0,
y: (height - crop_height) / 2.0,
width: crop_width,
height: crop_height,
})
}
fn capture_area_snapshot(selected: SelectedArea) -> Result<PreviewSnapshot> {
let (conn, screen_num) = connect(None).context("failed to connect to X11 for area preview")?;
let setup = conn.setup();
let screen = &setup.roots[screen_num];
let screen_width = i32::from(screen.width_in_pixels);
let screen_height = i32::from(screen.height_in_pixels);
let x = selected.area.x.clamp(0, screen_width.saturating_sub(1));
let y = selected.area.y.clamp(0, screen_height.saturating_sub(1));
let right = selected.area.right().min(screen_width).max(x);
let bottom = selected.area.bottom().min(screen_height).max(y);
let width = right - x;
let height = bottom - y;
ensure!(width > 0 && height > 0, "selected preview area is empty");
ensure!(
i64::from(width) * i64::from(height) <= PREVIEW_CAPTURE_MAX_PIXELS,
"selected area is too large for a live preview"
);
let reply = conn
.get_image(
ImageFormat::Z_PIXMAP,
screen.root,
i16::try_from(x).context("preview x is outside X11 coordinate range")?,
i16::try_from(y).context("preview y is outside X11 coordinate range")?,
u16::try_from(width).context("preview width is outside X11 size range")?,
u16::try_from(height).context("preview height is outside X11 size range")?,
u32::MAX,
)
.context("failed to request X11 preview image")?
.reply()
.context("failed to read X11 preview image")?;
let format = setup
.pixmap_formats
.iter()
.find(|format| format.depth == reply.depth)
.context("X11 preview format is unsupported")?;
let visual = find_visual(screen, reply.visual)
.or_else(|| find_visual(screen, screen.root_visual))
.context("X11 preview visual is unsupported")?;
let source_stride = x11_image_stride(width, format.bits_per_pixel, format.scanline_pad)
.context("X11 preview stride is unsupported")?;
let bytes_per_pixel = usize::from(format.bits_per_pixel).div_ceil(8);
ensure!(
bytes_per_pixel > 0 && bytes_per_pixel <= 4,
"X11 preview pixel size is unsupported"
);
ensure!(
reply.data.len() >= source_stride.saturating_mul(usize::try_from(height)?),
"X11 preview image data is truncated"
);
let mut surface = gtk::cairo::ImageSurface::create(gtk::cairo::Format::Rgb24, width, height)
.context("failed to allocate preview surface")?;
let target_stride = usize::try_from(surface.stride()).context("preview stride is invalid")?;
{
let mut data = surface
.data()
.context("failed to access preview surface data")?;
copy_x11_image_to_cairo(
&reply.data,
source_stride,
bytes_per_pixel,
setup.image_byte_order == ImageOrder::LSB_FIRST,
visual,
width,
height,
&mut data,
target_stride,
);
}
surface.mark_dirty();
Ok(PreviewSnapshot {
surface,
width,
height,
})
}
fn find_visual(screen: &Screen, visual_id: Visualid) -> Option<Visualtype> {
screen
.allowed_depths
.iter()
.flat_map(|depth| depth.visuals.iter())
.copied()
.find(|visual| visual.visual_id == visual_id)
}
fn x11_image_stride(width: i32, bits_per_pixel: u8, scanline_pad: u8) -> Option<usize> {
let width = usize::try_from(width).ok()?;
let bits_per_pixel = usize::from(bits_per_pixel);
let scanline_pad = usize::from(scanline_pad);
if width == 0 || bits_per_pixel == 0 || scanline_pad == 0 {
return None;
}
let bits = width.checked_mul(bits_per_pixel)?;
let padded_bits = bits.checked_add(scanline_pad - 1)? / scanline_pad * scanline_pad;
Some(padded_bits / 8)
}
#[allow(clippy::too_many_arguments)]
fn copy_x11_image_to_cairo(
source: &[u8],
source_stride: usize,
bytes_per_pixel: usize,
lsb_first: bool,
visual: Visualtype,
width: i32,
height: i32,
target: &mut [u8],
target_stride: usize,
) {
let width = usize::try_from(width).unwrap_or(0);
let height = usize::try_from(height).unwrap_or(0);
for y in 0..height {
let source_row = y * source_stride;
let target_row = y * target_stride;
for x in 0..width {
let source_offset = source_row + x * bytes_per_pixel;
let target_offset = target_row + x * 4;
let pixel = read_x11_pixel(source, source_offset, bytes_per_pixel, lsb_first);
let red = extract_x11_channel(pixel, visual.red_mask);
let green = extract_x11_channel(pixel, visual.green_mask);
let blue = extract_x11_channel(pixel, visual.blue_mask);
write_cairo_rgb24_pixel(target, target_offset, red, green, blue);
}
}
}
fn read_x11_pixel(source: &[u8], offset: usize, bytes_per_pixel: usize, lsb_first: bool) -> u32 {
let mut pixel = 0_u32;
for i in 0..bytes_per_pixel {
let byte = u32::from(*source.get(offset + i).unwrap_or(&0));
if lsb_first {
pixel |= byte << (i * 8);
} else {
pixel = (pixel << 8) | byte;
}
}
pixel
}
fn extract_x11_channel(pixel: u32, mask: u32) -> u8 {
if mask == 0 {
return 0;
}
let shift = mask.trailing_zeros();
let bits = mask.count_ones();
let value = (pixel & mask) >> shift;
let max = if bits >= u32::BITS {
u32::MAX
} else {
(1_u32 << bits).saturating_sub(1)
}
.max(1);
((value * 255 + max / 2) / max) as u8
}
fn write_cairo_rgb24_pixel(target: &mut [u8], offset: usize, red: u8, green: u8, blue: u8) {
if cfg!(target_endian = "little") {
target[offset] = blue;
target[offset + 1] = green;
target[offset + 2] = red;
target[offset + 3] = 0;
} else {
target[offset] = 0;
target[offset + 1] = red;
target[offset + 2] = green;
target[offset + 3] = blue;
}
}
fn create_crop_overlay(selected: SelectedArea) -> Result<CropOverlay> {
let (conn, screen_num) = connect(None).context("failed to connect to X11 for crop overlay")?;
let screen = &conn.setup().roots[screen_num];
let screen_width = i32::from(screen.width_in_pixels);
let screen_height = i32::from(screen.height_in_pixels);
let area = clamp_overlay_area(selected.area, screen_width, screen_height)
.context("selected crop is outside the current X11 screen")?;
let glow_rectangles =
crop_overlay_rectangles(area, screen_width, screen_height, CROP_OVERLAY_THICKNESS);
let edge_rectangles = crop_overlay_rectangles(
area,
screen_width,
screen_height,
CROP_OVERLAY_EDGE_THICKNESS,
);
ensure!(
!glow_rectangles.is_empty(),
"crop touches every screen edge, so no outside overlay can be shown"
);
let opacity_atom = conn
.intern_atom(false, b"_NET_WM_WINDOW_OPACITY")?
.reply()?
.atom;
let glow_pixel = x11_color_pixel(
&conn,
screen.default_colormap,
b"#00d5ff",
screen.white_pixel,
);
let edge_pixel = x11_color_pixel(
&conn,
screen.default_colormap,
b"#00e0a4",
screen.white_pixel,
);
let mut windows = Vec::with_capacity(2);
let result = (|| -> Result<()> {
windows.push(create_crop_overlay_window(
&conn,
screen,
opacity_atom,
&glow_rectangles,
glow_pixel,
b"Ocula Crop Outline",
)?);
if !edge_rectangles.is_empty() {
windows.push(create_crop_overlay_window(
&conn,
screen,
opacity_atom,
&edge_rectangles,
edge_pixel,
b"Ocula Crop Edge",
)?);
}
conn.flush()?;
Ok(())
})();
if let Err(err) = result {
destroy_crop_overlay_windows(&conn, &windows);
return Err(err);
}
Ok(CropOverlay {
conn,
windows,
selected,
})
}
fn create_crop_overlay_window(
conn: &RustConnection,
screen: &Screen,
opacity_atom: u32,
rectangles: &[Rectangle],
color: u32,
name: &[u8],
) -> Result<Window> {
let window = conn.generate_id()?;
let result = (|| -> Result<()> {
conn.create_window(
COPY_DEPTH_FROM_PARENT,
window,
screen.root,
0,
0,
screen.width_in_pixels,
screen.height_in_pixels,
0,
WindowClass::INPUT_OUTPUT,
0,
&CreateWindowAux::new()
.background_pixel(color)
.override_redirect(1),
)?
.check()?;
conn.change_property32(
PropMode::REPLACE,
window,
opacity_atom,
AtomEnum::CARDINAL,
&[CROP_OVERLAY_OPACITY],
)?
.check()?;
conn.change_property8(
PropMode::REPLACE,
window,
AtomEnum::WM_NAME,
AtomEnum::STRING,
name,
)?
.check()?;
conn.shape_rectangles(
SO::SET,
SK::BOUNDING,
ClipOrdering::UNSORTED,
window,
0,
0,
rectangles,
)?
.check()?;
conn.shape_rectangles(
SO::SET,
SK::INPUT,
ClipOrdering::UNSORTED,
window,
0,
0,
&[],
)?
.check()?;
conn.map_window(window)?.check()?;
conn.configure_window(
window,
&ConfigureWindowAux::new().stack_mode(StackMode::ABOVE),
)?
.check()?;
Ok(())
})();
if let Err(err) = result {
destroy_crop_overlay_windows(conn, &[window]);
return Err(err);
}
Ok(window)
}
fn destroy_crop_overlay_windows(conn: &RustConnection, windows: &[Window]) {
for window in windows {
let _ = conn.destroy_window(*window);
}
let _ = conn.flush();
}
fn x11_color_pixel(conn: &RustConnection, colormap: Colormap, name: &[u8], fallback: u32) -> u32 {
conn.alloc_named_color(colormap, name)
.ok()
.and_then(|cookie| cookie.reply().ok())
.map_or(fallback, |reply| reply.pixel)
}
fn clamp_overlay_area(
area: CaptureArea,
screen_width: i32,
screen_height: i32,
) -> Option<CaptureArea> {
let left = area.x.clamp(0, screen_width.max(0));
let top = area.y.clamp(0, screen_height.max(0));
let right = area.right().clamp(0, screen_width.max(0));
let bottom = area.bottom().clamp(0, screen_height.max(0));
CaptureArea::new(left, top, right - left, bottom - top).ok()
}
fn crop_overlay_rectangles(
area: CaptureArea,
screen_width: i32,
screen_height: i32,
thickness: i32,
) -> Vec<Rectangle> {
let thickness = thickness.max(1);
let left = area.x;
let top = area.y;
let right = area.right();
let bottom = area.bottom();
[
overlay_rect(
left - thickness,
top - thickness,
area.width + thickness * 2,
thickness,
screen_width,
screen_height,
),
overlay_rect(
left - thickness,
bottom,
area.width + thickness * 2,
thickness,
screen_width,
screen_height,
),
overlay_rect(
left - thickness,
top,
thickness,
area.height,
screen_width,
screen_height,
),
overlay_rect(
right,
top,
thickness,
area.height,
screen_width,
screen_height,
),
]
.into_iter()
.flatten()
.collect()
}
fn overlay_rect(
x: i32,
y: i32,
width: i32,
height: i32,
screen_width: i32,
screen_height: i32,
) -> Option<Rectangle> {
if width <= 0 || height <= 0 {
return None;
}
let left = x.clamp(0, screen_width.max(0));
let top = y.clamp(0, screen_height.max(0));
let right = x.saturating_add(width).clamp(0, screen_width.max(0));
let bottom = y.saturating_add(height).clamp(0, screen_height.max(0));
if right <= left || bottom <= top {
return None;
}
Some(Rectangle {
x: i16::try_from(left).ok()?,
y: i16::try_from(top).ok()?,
width: u16::try_from(right - left).ok()?,
height: u16::try_from(bottom - top).ok()?,
})
}
fn connect_mode_buttons(ui: &Ui) {
let ui_for_screen = ui.clone();
ui.screen_button.clone().connect_toggled(move |button| {
if button.is_active() {
update_area_controls(&ui_for_screen);
}
});
let ui_for_window = ui.clone();
ui.window_button.clone().connect_toggled(move |button| {
if button.is_active() {
update_area_controls(&ui_for_window);
}
});
let ui_for_area = ui.clone();
ui.area_button.clone().connect_toggled(move |button| {
if button.is_active() {
update_area_controls(&ui_for_area);
}
});
}
fn connect_area_selector(ui: &Ui) {
let ui = ui.clone();
ui.select_area_button.clone().connect_clicked(move |_| {
open_area_selector(&ui);
});
}
fn connect_area_preview_selector(ui: &Ui) {
let click = gtk::GestureClick::new();
click.set_button(1);
let ui_for_preview = ui.clone();
click.connect_released(move |_, _, _, _| {
if area_preview_can_open_selector(&ui_for_preview) {
open_area_selector(&ui_for_preview);
}
});
ui.area_preview.add_controller(click);
let key = gtk::EventControllerKey::new();
let ui_for_key = ui.clone();
key.connect_key_pressed(move |_, key, _, _| {
let activates = matches!(
key,
gtk::gdk::Key::Return | gtk::gdk::Key::KP_Enter | gtk::gdk::Key::space
);
if activates && area_preview_can_open_selector(&ui_for_key) {
open_area_selector(&ui_for_key);
glib::Propagation::Stop
} else {
glib::Propagation::Proceed
}
});
ui.area_preview.add_controller(key);
}
fn area_preview_can_open_selector(ui: &Ui) -> bool {
selected_mode(ui) == Mode::Area && ui.child.borrow().is_none()
}
fn open_area_selector(ui: &Ui) {
let previous = *ui.selected_area.borrow();
ui.crop_overlay.borrow_mut().take();
ui.crop_overlay_failed.set(None);
if let Some(selected) = previous {
ui.status_label
.set_text("Move or resize the area, then confirm");
append_log(
&ui.log_buffer,
&format!(
"Opening adjustable area selector at {}",
area_label(selected)
),
);
} else {
ui.status_label
.set_text("Drag an area, refine it, then confirm");
append_log(&ui.log_buffer, "Opening studio area selector");
}
ui.window.set_visible(false);
while glib::MainContext::default().iteration(false) {}
let selection = if let Some(selected) = previous {
selector::adjust_area(selected.area)
} else {
selector::select_area()
};
match selection {
Ok(selection) => {
let selected = SelectedArea {
area: selection.area,
screen_width: selection.screen_width,
screen_height: selection.screen_height,
};
match capture_area_snapshot(selected) {
Ok(snapshot) => {
ui.preview_snapshot.replace(Some(snapshot));
append_log(&ui.log_buffer, "Captured selected-area preview");
}
Err(err) => {
ui.preview_snapshot.replace(None);
append_log(
&ui.log_buffer,
&format!("Live area preview unavailable: {err:#}"),
);
}
}
ui.selected_area.replace(Some(selected));
ui.area_preview.queue_draw();
ui.status_label.set_text("Area selected");
append_log(
&ui.log_buffer,
&format!("Selected {}", area_label(selected)),
);
}
Err(err) => {
ui.status_label.set_text("Area selection cancelled");
append_log(&ui.log_buffer, &format!("Area selection failed: {err:#}"));
}
}
ui.window.present();
update_area_controls(ui);
}
fn update_area_controls(ui: &Ui) {
let is_area = selected_mode(ui) == Mode::Area;
let is_recording = ui.child.borrow().is_some();
let has_area = ui.selected_area.borrow().is_some();
ui.area_row.set_visible(is_area);
ui.area_preview.set_visible(is_area);
if let Some(selected) = *ui.selected_area.borrow() {
ui.area_label.set_text(&area_label(selected));
ui.area_label.remove_css_class("dim-label");
ui.area_label.add_css_class("ocula-area-selected");
ui.select_area_button.set_label("Refine Crop");
ui.select_area_button.add_css_class("ocula-crop-ready");
ui.area_preview.queue_draw();
} else {
ui.area_label.set_text("No area selected");
ui.area_label.add_css_class("dim-label");
ui.area_label.remove_css_class("ocula-area-selected");
ui.select_area_button.set_label("Pick Crop");
ui.select_area_button.remove_css_class("ocula-crop-ready");
}
let preview_tooltip = if is_recording {
"Crop controls are locked while recording"
} else if has_area {
"Click or press Enter to refine the selected crop"
} else {
"Click or press Enter to pick a recording crop"
};
ui.area_preview.set_tooltip_text(Some(preview_tooltip));
if is_area {
ui.area_preview.queue_draw();
}
ui.controls_card.remove_css_class("ocula-area-ready");
ui.controls_card.remove_css_class("ocula-recording");
ui.record_button.remove_css_class("ocula-recording");
if is_recording {
ui.controls_card.add_css_class("ocula-recording");
ui.record_button.add_css_class("ocula-recording");
} else if is_area && has_area {
ui.controls_card.add_css_class("ocula-area-ready");
}
sync_crop_overlay(ui);
if !is_recording {
ui.record_button.set_sensitive(!is_area || has_area);
ui.select_area_button.set_sensitive(true);
} else {
ui.record_button.set_sensitive(true);
ui.select_area_button.set_sensitive(false);
}
}
fn sync_crop_overlay(ui: &Ui) {
let desired = if selected_mode(ui) == Mode::Area {
*ui.selected_area.borrow()
} else {
None
};
if let Some(selected) = desired {
if ui
.crop_overlay
.borrow()
.as_ref()
.is_some_and(|overlay| overlay.selected == selected)
{
return;
}
ui.crop_overlay.borrow_mut().take();
if ui.crop_overlay_failed.get() == Some(selected) {
return;
}
match create_crop_overlay(selected) {
Ok(overlay) => {
ui.crop_overlay_failed.set(None);
ui.crop_overlay.replace(Some(overlay));
}
Err(err) => {
ui.crop_overlay_failed.set(Some(selected));
append_log(
&ui.log_buffer,
&format!("Crop outline unavailable: {err:#}"),
);
}
}
} else {
ui.crop_overlay.borrow_mut().take();
ui.crop_overlay_failed.set(None);
}
}
fn connect_format_picker(ui: &Ui) {
let ui = ui.clone();
ui.format_dropdown
.clone()
.connect_selected_notify(move |_| {
update_format_controls(&ui);
ui.output_entry
.set_placeholder_text(Some(&default_output_hint(selected_output_format(&ui))));
normalize_output_extension(&ui);
});
}
fn connect_effect_picker(ui: &Ui) {
let preview = ui.area_preview.clone();
ui.effect_dropdown
.clone()
.connect_selected_notify(move |_| preview.queue_draw());
}
fn update_format_controls(ui: &Ui) {
if selected_output_format(ui) == OutputFormatChoice::Gif {
ui.audio_dropdown.set_selected(0);
ui.audio_dropdown.set_sensitive(false);
ui.encoder_dropdown.set_sensitive(false);
ui.status_label
.set_text("GIF records video only; audio is disabled");
} else {
ui.audio_dropdown.set_sensitive(true);
ui.encoder_dropdown.set_sensitive(true);
if ui.child.borrow().is_none() {
ui.status_label.set_text("Ready");
}
}
}
fn normalize_output_extension(ui: &Ui) {
let output = ui.output_entry.text().trim().to_string();
if output.is_empty() {
return;
}
let mut path = PathBuf::from(output);
let extension = path.extension().and_then(|extension| extension.to_str());
if extension.is_none()
|| extension.is_some_and(|extension| {
["mkv", "mp4", "gif"]
.iter()
.any(|known| extension.eq_ignore_ascii_case(known))
})
{
path.set_extension(selected_output_format(ui).extension());
ui.output_entry.set_text(&path.to_string_lossy());
}
}
fn connect_output_chooser(ui: &Ui, browse_button: >k::Button) {
let ui = ui.clone();
browse_button.connect_clicked(move |_| {
let ui = ui.clone();
glib::spawn_future_local(async move {
let folder = gio::File::for_path(default_output_dir());
let dialog = gtk::FileDialog::builder()
.title("Save Recording")
.initial_folder(&folder)
.initial_name(format!(
"Recording.{}",
selected_output_format(&ui).extension()
))
.modal(true)
.build();
match dialog.save_future(Some(&ui.window)).await {
Ok(file) => {
if let Some(path) = file.path() {
ui.output_entry.set_text(&path.to_string_lossy());
}
}
Err(err) if err.matches(gtk::DialogError::Dismissed) => {}
Err(err) => append_log(&ui.log_buffer, &format!("Output chooser failed: {err}")),
}
});
});
}
fn connect_record_button(ui: &Ui) {
let ui = ui.clone();
ui.record_button.clone().connect_clicked(move |_| {
if ui.child.borrow().is_some() {
if ui.stop_started_at.borrow().is_some() {
force_stop_recording(&ui);
} else {
request_graceful_stop(&ui, false);
}
} else {
start_recording(&ui);
}
});
}
fn connect_window_shutdown(ui: &Ui) {
let ui_for_close = ui.clone();
ui.window.clone().connect_close_request(move |_| {
if ui_for_close.child.borrow().is_some() {
ui_for_close.close_after_child_exit.set(true);
request_graceful_stop(&ui_for_close, true);
glib::Propagation::Stop
} else {
glib::Propagation::Proceed
}
});
let destroyed = Rc::clone(&ui.window_destroyed);
ui.window.connect_destroy(move |_| {
destroyed.set(true);
});
}
fn install_child_poll(ui: &Ui) {
let ui = ui.clone();
glib::timeout_add_local(Duration::from_millis(100), move || {
for _ in 0..LOG_DRAIN_LINES_PER_TICK {
let Ok(line) = ui.log_rx.borrow().try_recv() else {
break;
};
append_log(&ui.log_buffer, &line);
}
let status = {
let mut child_ref = ui.child.borrow_mut();
match child_ref.as_mut() {
Some(child) => child.try_wait().transpose(),
None => None,
}
};
if let Some(result) = status {
ui.child.borrow_mut().take();
reset_stop_state(&ui);
reset_record_button(&ui);
match result {
Ok(exit) if exit.success() => ui.status_label.set_text("Recording saved"),
Ok(exit) => ui
.status_label
.set_text(&format!("Recorder exited with {exit}")),
Err(err) => ui
.status_label
.set_text(&format!("Failed to inspect recorder: {err}")),
}
update_area_controls(&ui);
if ui.close_after_child_exit.replace(false) {
ui.window.close();
}
} else {
enforce_stop_deadlines(&ui);
}
if ui.window_destroyed.get() && ui.child.borrow().is_none() {
return glib::ControlFlow::Break;
}
glib::ControlFlow::Continue
});
}
fn start_recording(ui: &Ui) {
if selected_mode(ui) == Mode::Area && ui.selected_area.borrow().is_none() {
ui.status_label.set_text("Select an area before recording");
append_log(&ui.log_buffer, "Area mode requires selecting an area first");
update_area_controls(ui);
return;
}
normalize_output_extension(ui);
let args = recorder_args(ui);
let command_path = recorder_command_path();
append_log(
&ui.log_buffer,
&format!("Starting: {} {}", command_path.display(), args.join(" ")),
);
let mut command = Command::new(&command_path);
command
.args(&args)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
configure_recorder_command(&mut command);
match command.spawn() {
Ok(mut child) => {
if let Some(stdout) = child.stdout.take() {
spawn_log_reader(stdout, ui.log_tx.clone());
}
if let Some(stderr) = child.stderr.take() {
spawn_log_reader(stderr, ui.log_tx.clone());
}
ui.child.replace(Some(child));
reset_stop_state(ui);
ui.close_after_child_exit.set(false);
ui.status_label
.set_text("Recording... press Stop to finish safely");
ui.record_button.set_label("Stop Recording");
ui.record_button.set_sensitive(true);
ui.record_button.remove_css_class("suggested-action");
ui.record_button.add_css_class("destructive-action");
update_area_controls(ui);
}
Err(err) => {
let message = format!(
"Could not start recorder `{}`: {err}",
command_path.display()
);
ui.status_label.set_text("Could not start recorder");
append_log(&ui.log_buffer, &message);
}
}
}
fn request_graceful_stop(ui: &Ui, close_after_stop: bool) {
if ui.stop_started_at.borrow().is_some() {
return;
}
let result = ui
.child
.borrow()
.as_ref()
.map(|child| interrupt_recorder_process(child.id()));
match result {
Some(Ok(())) => {
ui.stop_started_at.replace(Some(Instant::now()));
ui.close_after_child_exit.set(close_after_stop);
if close_after_stop {
ui.status_label
.set_text("Stopping safely before closing...");
} else {
ui.status_label
.set_text("Stopping safely... click Force Stop if it hangs");
}
ui.record_button.set_label("Force Stop");
ui.record_button.set_sensitive(true);
}
Some(Err(err)) => append_log(&ui.log_buffer, &format!("Failed to stop recorder: {err}")),
None => {}
}
}
fn force_stop_recording(ui: &Ui) {
if ui.force_stop_started_at.borrow().is_some() {
return;
}
let result = ui
.child
.borrow()
.as_ref()
.map(|child| interrupt_recorder_process(child.id()));
match result {
Some(Ok(())) => {
ui.force_stop_started_at.replace(Some(Instant::now()));
ui.status_label
.set_text("Force stop requested; waiting briefly...");
ui.record_button.set_sensitive(false);
}
Some(Err(err)) => {
append_log(
&ui.log_buffer,
&format!("Failed to force stop recorder: {err}"),
);
kill_recording_child(ui, "signal failed");
}
None => {}
}
}
fn enforce_stop_deadlines(ui: &Ui) {
let Some(stopped_at) = *ui.stop_started_at.borrow() else {
return;
};
if ui.force_stop_started_at.borrow().is_none() && stopped_at.elapsed() >= STOP_GRACE {
append_log(
&ui.log_buffer,
"Recorder did not stop in time; requesting force stop",
);
force_stop_recording(ui);
return;
}
if let Some(force_started_at) = *ui.force_stop_started_at.borrow()
&& force_started_at.elapsed() >= FORCE_KILL_GRACE
{
kill_recording_child(ui, "force stop timed out");
}
}
fn kill_recording_child(ui: &Ui, reason: &str) {
if ui.kill_sent.replace(true) {
return;
}
let result = ui.child.borrow_mut().as_mut().map(kill_recorder_process);
match result {
Some(Ok(())) => {
ui.status_label
.set_text("Recorder killed; waiting for exit...");
append_log(
&ui.log_buffer,
&format!("Killed recorder process: {reason}"),
);
}
Some(Err(err)) => append_log(&ui.log_buffer, &format!("Failed to kill recorder: {err}")),
None => {}
}
}
fn reset_stop_state(ui: &Ui) {
ui.stop_started_at.replace(None);
ui.force_stop_started_at.replace(None);
ui.kill_sent.set(false);
}
fn reset_record_button(ui: &Ui) {
ui.record_button.set_sensitive(true);
ui.record_button.set_label("Start Recording");
ui.record_button.add_css_class("suggested-action");
ui.record_button.remove_css_class("destructive-action");
}
fn spawn_log_reader(reader: impl Read + Send + 'static, tx: mpsc::SyncSender<String>) {
thread::spawn(move || {
read_log_stream(reader, &tx);
});
}
fn read_log_stream(mut reader: impl Read, tx: &mpsc::SyncSender<String>) {
let mut buffer = [0_u8; LOG_READ_CHUNK_BYTES];
let mut line = Vec::with_capacity(LOG_LINE_MAX_BYTES.min(LOG_READ_CHUNK_BYTES));
let mut truncated = false;
loop {
match reader.read(&mut buffer) {
Ok(0) => {
flush_log_line(tx, &mut line, &mut truncated);
break;
}
Ok(bytes_read) => {
for &byte in &buffer[..bytes_read] {
if byte == b'\n' {
if !flush_log_line(tx, &mut line, &mut truncated) {
return;
}
} else if line.len() < LOG_LINE_MAX_BYTES {
line.push(byte);
} else {
truncated = true;
}
}
}
Err(err) => {
let _ = queue_log_line(tx, format!("Failed to read recorder output: {err}"));
break;
}
}
}
}
fn flush_log_line(tx: &mpsc::SyncSender<String>, line: &mut Vec<u8>, truncated: &mut bool) -> bool {
if line.is_empty() && !*truncated {
return true;
}
let mut text = String::from_utf8_lossy(line).into_owned();
if *truncated {
text.push_str(" ... [truncated]");
}
line.clear();
*truncated = false;
queue_log_line(tx, text)
}
fn queue_log_line(tx: &mpsc::SyncSender<String>, line: String) -> bool {
match tx.try_send(line) {
Ok(()) | Err(mpsc::TrySendError::Full(_)) => true,
Err(mpsc::TrySendError::Disconnected(_)) => false,
}
}
fn recorder_args(ui: &Ui) -> Vec<String> {
let mode = selected_mode(ui);
let mut args = vec!["--no-prompt".to_string(), "--mode".to_string()];
args.push(
match mode {
Mode::Screen => "screen",
Mode::Window => "window",
Mode::Area => "area",
}
.to_string(),
);
if mode == Mode::Area
&& let Some(selected) = *ui.selected_area.borrow()
{
args.push("--area".to_string());
args.push(capture_area_arg(selected.area));
args.push("--area-screen".to_string());
args.push(area_screen_arg(selected));
}
args.push("--audio".to_string());
args.push(audio_arg(ui.audio_dropdown.selected()).to_string());
args.push("--format".to_string());
args.push(selected_output_format(ui).cli_arg().to_string());
args.push("--h264-encoder".to_string());
args.push(h264_encoder_arg(ui.encoder_dropdown.selected()).to_string());
args.push("--effect".to_string());
args.push(effect_arg(ui.effect_dropdown.selected()).to_string());
if ui.hide_pointer_switch.is_active() {
args.push("--hide-pointer".to_string());
}
let output = ui.output_entry.text().trim().to_string();
if output.is_empty() {
args.push("--dir".to_string());
args.push(default_output_dir().to_string_lossy().into_owned());
} else {
args.push("--output".to_string());
args.push(output);
}
args
}
fn capture_area_arg(area: CaptureArea) -> String {
format!("{},{},{},{}", area.x, area.y, area.width, area.height)
}
fn area_screen_arg(selected: SelectedArea) -> String {
format!("{}x{}", selected.screen_width, selected.screen_height)
}
fn area_label(selected: SelectedArea) -> String {
format!(
"{}x{} at {},{}",
selected.area.width, selected.area.height, selected.area.x, selected.area.y
)
}
fn selected_mode(ui: &Ui) -> Mode {
if ui.window_button.is_active() {
Mode::Window
} else if ui.area_button.is_active() {
Mode::Area
} else {
debug_assert!(ui.screen_button.is_active());
Mode::Screen
}
}
fn audio_arg(selected: u32) -> &'static str {
match selected {
1 => "mic",
2 => "system",
3 => "both",
_ => "none",
}
}
fn h264_encoder_arg(selected: u32) -> &'static str {
match selected {
1 => "hardware",
2 => "software",
_ => "auto",
}
}
fn effect_arg(selected: u32) -> &'static str {
match selected {
1 => "neon",
2 => "heatmap",
3 => "glitch",
4 => "ripple",
5 => "glass",
6 => "crt",
7 => "holo",
8 => "vapor",
9 => "fracture",
10 => "prism",
11 => "aurora",
12 => "kaleido",
_ => "none",
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum OutputFormatChoice {
Mkv,
Mp4,
Gif,
}
impl OutputFormatChoice {
fn extension(self) -> &'static str {
match self {
Self::Mkv => "mkv",
Self::Mp4 => "mp4",
Self::Gif => "gif",
}
}
fn cli_arg(self) -> &'static str {
match self {
Self::Mkv => "mkv",
Self::Mp4 => "mp4",
Self::Gif => "gif",
}
}
}
fn selected_output_format(ui: &Ui) -> OutputFormatChoice {
match ui.format_dropdown.selected() {
1 => OutputFormatChoice::Mp4,
2 => OutputFormatChoice::Gif,
_ => OutputFormatChoice::Mkv,
}
}
fn recorder_command_path() -> PathBuf {
if let Ok(current) = env::current_exe()
&& let Some(candidate) = sibling_recorder_path(¤t)
&& candidate.exists()
{
return candidate;
}
PathBuf::from("oculo")
}
fn sibling_recorder_path(current: &Path) -> Option<PathBuf> {
let file_name = current.file_name()?.to_str()?;
if file_name == "ocula" {
Some(current.with_file_name("oculo"))
} else {
None
}
}
fn default_output_hint(format: OutputFormatChoice) -> String {
format!(
"Auto .{} in {}",
format.extension(),
default_output_dir().display()
)
}
fn default_output_dir() -> PathBuf {
glib::user_special_dir(glib::UserDirectory::Videos)
.unwrap_or_else(glib::home_dir)
.join("Ocula")
}
fn append_log(buffer: >k::TextBuffer, text: &str) {
let mut iter = buffer.end_iter();
buffer.insert(&mut iter, text);
buffer.insert(&mut iter, "\n");
trim_log_buffer(buffer);
}
fn trim_log_buffer(buffer: >k::TextBuffer) {
while buffer.line_count() > LOG_SCROLLBACK_LINES {
let mut start = buffer.start_iter();
let Some(mut end) = buffer.iter_at_line(1) else {
break;
};
buffer.delete(&mut start, &mut end);
}
let excess_chars = buffer.char_count() - LOG_SCROLLBACK_CHARS;
if excess_chars > 0 {
let mut start = buffer.start_iter();
let mut end = buffer.iter_at_offset(excess_chars);
buffer.delete(&mut start, &mut end);
}
}
fn configure_recorder_command(command: &mut Command) {
configure_recorder_process_group(command);
}
#[cfg(unix)]
fn configure_recorder_process_group(command: &mut Command) {
unsafe {
command.pre_exec(|| {
let result = libc::setpgid(0, 0);
if result == 0 {
Ok(())
} else {
Err(std::io::Error::last_os_error())
}
});
}
}
#[cfg(not(unix))]
fn configure_recorder_process_group(_command: &mut Command) {}
#[cfg(unix)]
fn interrupt_recorder_process(pid: u32) -> Result<(), std::io::Error> {
signal_recorder_process_group(pid, libc::SIGINT)
}
#[cfg(not(unix))]
fn interrupt_recorder_process(_pid: u32) -> Result<(), std::io::Error> {
Err(std::io::Error::new(
std::io::ErrorKind::Unsupported,
"safe stop from the GUI is only implemented on Unix",
))
}
#[cfg(unix)]
fn kill_recorder_process(child: &mut Child) -> Result<(), std::io::Error> {
match signal_recorder_process_group(child.id(), libc::SIGKILL) {
Ok(()) => Ok(()),
Err(group_err) => child.kill().map_err(|child_err| {
std::io::Error::new(
child_err.kind(),
format!(
"failed to kill recorder process group: {group_err}; failed to kill child: {child_err}"
),
)
}),
}
}
#[cfg(not(unix))]
fn kill_recorder_process(child: &mut Child) -> Result<(), std::io::Error> {
child.kill()
}
#[cfg(unix)]
fn signal_recorder_process_group(pid: u32, signal: libc::c_int) -> Result<(), std::io::Error> {
let pid = pid_to_pid_t(pid)?;
let result = unsafe { libc::kill(-pid, signal) };
if result == 0 {
Ok(())
} else {
Err(std::io::Error::last_os_error())
}
}
#[cfg(unix)]
fn pid_to_pid_t(pid: u32) -> Result<libc::pid_t, std::io::Error> {
let pid = libc::pid_t::try_from(pid).map_err(|_| {
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!("child pid {pid} does not fit pid_t"),
)
})?;
if pid == 0 {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"child pid must not be zero",
));
}
Ok(pid)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn version_flag_is_handled_before_gtk() {
assert!(version_requested(["--version"]));
assert!(version_requested(["-V"]));
assert!(version_requested(["--display", ":1", "--version"]));
assert!(!version_requested(["--help"]));
}
#[test]
fn audio_selection_maps_to_cli_args() {
assert_eq!(audio_arg(0), "none");
assert_eq!(audio_arg(1), "mic");
assert_eq!(audio_arg(2), "system");
assert_eq!(audio_arg(3), "both");
assert_eq!(audio_arg(99), "none");
}
#[test]
fn h264_encoder_selection_maps_to_cli_args() {
assert_eq!(h264_encoder_arg(0), "auto");
assert_eq!(h264_encoder_arg(1), "hardware");
assert_eq!(h264_encoder_arg(2), "software");
assert_eq!(h264_encoder_arg(99), "auto");
}
#[test]
fn effect_selection_maps_to_cli_args() {
assert_eq!(effect_arg(0), "none");
assert_eq!(effect_arg(1), "neon");
assert_eq!(effect_arg(2), "heatmap");
assert_eq!(effect_arg(3), "glitch");
assert_eq!(effect_arg(4), "ripple");
assert_eq!(effect_arg(5), "glass");
assert_eq!(effect_arg(6), "crt");
assert_eq!(effect_arg(7), "holo");
assert_eq!(effect_arg(8), "vapor");
assert_eq!(effect_arg(9), "fracture");
assert_eq!(effect_arg(10), "prism");
assert_eq!(effect_arg(11), "aurora");
assert_eq!(effect_arg(12), "kaleido");
assert_eq!(effect_arg(99), "none");
}
#[test]
fn x11_channel_extraction_scales_visual_masks() {
assert_eq!(extract_x11_channel(0x00ff0000, 0x00ff0000), 255);
assert_eq!(extract_x11_channel(0x00008000, 0x0000ff00), 128);
assert_eq!(extract_x11_channel(0x0000001f, 0x0000001f), 255);
assert_eq!(extract_x11_channel(0, 0), 0);
}
#[test]
fn x11_stride_respects_scanline_padding() {
assert_eq!(x11_image_stride(3, 24, 32), Some(12));
assert_eq!(x11_image_stride(3, 32, 32), Some(12));
assert_eq!(x11_image_stride(0, 32, 32), None);
}
#[test]
fn crop_overlay_rectangles_stay_outside_crop() {
let area = CaptureArea::new(20, 10, 60, 50).unwrap();
let rectangles = crop_overlay_rectangles(area, 140, 120, 8);
assert!(rectangles_contain_point(&rectangles, (50, 5)));
assert!(rectangles_contain_point(&rectangles, (15, 30)));
assert!(rectangles_contain_point(&rectangles, (80, 30)));
assert!(rectangles_contain_point(&rectangles, (50, 60)));
assert!(!rectangles_contain_point(&rectangles, (50, 30)));
}
#[test]
fn crop_overlay_is_empty_for_full_screen_crop() {
let area = CaptureArea::new(0, 0, 140, 120).unwrap();
assert!(crop_overlay_rectangles(area, 140, 120, 8).is_empty());
}
#[test]
fn output_format_choice_maps_to_extension_and_cli_arg() {
assert_eq!(OutputFormatChoice::Mkv.extension(), "mkv");
assert_eq!(OutputFormatChoice::Mkv.cli_arg(), "mkv");
assert_eq!(OutputFormatChoice::Mp4.extension(), "mp4");
assert_eq!(OutputFormatChoice::Mp4.cli_arg(), "mp4");
assert_eq!(OutputFormatChoice::Gif.extension(), "gif");
assert_eq!(OutputFormatChoice::Gif.cli_arg(), "gif");
}
#[test]
fn sibling_recorder_removes_gui_suffix() {
assert_eq!(
sibling_recorder_path(Path::new("/tmp/ocula")).unwrap(),
PathBuf::from("/tmp/oculo"),
);
assert!(sibling_recorder_path(Path::new("/tmp/oculo")).is_none());
}
#[test]
fn selected_area_formats_cli_args_and_label() {
let selected = SelectedArea {
area: CaptureArea::new(10, 20, 160, 120).unwrap(),
screen_width: 1920,
screen_height: 1080,
};
assert_eq!(capture_area_arg(selected.area), "10,20,160,120");
assert_eq!(area_screen_arg(selected), "1920x1080");
assert_eq!(area_label(selected), "160x120 at 10,20");
}
#[test]
fn preview_rect_preserves_selected_area_position_and_size() {
let selected = SelectedArea {
area: CaptureArea::new(100, 50, 400, 300).unwrap(),
screen_width: 1000,
screen_height: 500,
};
let rect = preview_selection_rect(selected, 528.0, 278.0).unwrap();
assert!((rect.x - 64.0).abs() < 0.1);
assert!((rect.y - 39.0).abs() < 0.1);
assert!((rect.width - 200.0).abs() < 0.1);
assert!((rect.height - 150.0).abs() < 0.1);
}
#[test]
fn preview_rect_rejects_invalid_screen_size() {
let selected = SelectedArea {
area: CaptureArea::new(0, 0, 100, 100).unwrap(),
screen_width: 0,
screen_height: 500,
};
assert!(preview_selection_rect(selected, 320.0, 180.0).is_none());
}
#[test]
fn preview_crop_rect_fills_available_card() {
let rect = preview_crop_rect(160, 120, 320.0, 180.0).unwrap();
assert!((rect.x - 61.3).abs() < 0.2);
assert!((rect.y - 16.0).abs() < 0.1);
assert!((rect.width - 197.3).abs() < 0.2);
assert!((rect.height - 148.0).abs() < 0.1);
}
#[test]
fn log_queue_drops_when_full_instead_of_blocking() {
let (tx, rx) = mpsc::sync_channel(1);
assert!(queue_log_line(&tx, "first".to_string()));
assert!(queue_log_line(&tx, "dropped".to_string()));
assert_eq!(rx.try_recv().unwrap(), "first");
assert!(rx.try_recv().is_err());
}
#[test]
fn log_queue_reports_disconnected_receiver() {
let (tx, rx) = mpsc::sync_channel(1);
drop(rx);
assert!(!queue_log_line(&tx, "ignored".to_string()));
}
#[test]
fn log_stream_reader_truncates_long_lines() {
let (tx, rx) = mpsc::sync_channel(4);
let input = vec![b'x'; LOG_LINE_MAX_BYTES + 128];
read_log_stream(std::io::Cursor::new(input), &tx);
let line = rx.try_recv().unwrap();
assert!(line.len() <= LOG_LINE_MAX_BYTES + " ... [truncated]".len());
assert!(line.ends_with(" ... [truncated]"));
assert!(rx.try_recv().is_err());
}
#[test]
fn log_stream_reader_splits_lines() {
let (tx, rx) = mpsc::sync_channel(4);
read_log_stream(std::io::Cursor::new(b"one\ntwo\n"), &tx);
assert_eq!(rx.try_recv().unwrap(), "one");
assert_eq!(rx.try_recv().unwrap(), "two");
assert!(rx.try_recv().is_err());
}
#[cfg(unix)]
#[test]
fn pid_conversion_rejects_zero() {
assert!(pid_to_pid_t(0).is_err());
}
#[cfg(unix)]
#[test]
fn pid_conversion_accepts_normal_pid() {
assert_eq!(pid_to_pid_t(1).unwrap(), 1);
}
#[cfg(all(unix, target_pointer_width = "64"))]
#[test]
fn pid_conversion_rejects_out_of_range_pid() {
assert!(pid_to_pid_t(i32::MAX as u32 + 1).is_err());
}
fn rectangles_contain_point(rectangles: &[Rectangle], point: (i16, i16)) -> bool {
rectangles.iter().any(|rectangle| {
point.0 >= rectangle.x
&& point.1 >= rectangle.y
&& point.0 < rectangle.x + rectangle.width as i16
&& point.1 < rectangle.y + rectangle.height as i16
})
}
}