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, bail, ensure};
use gtk::{gio, glib, pango};
use ocula::{
pipeline::{CaptureArea, VideoEffect},
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 LIVE_PREVIEW_REFRESH: Duration = Duration::from_millis(850);
const CROP_OVERLAY_THICKNESS: i32 = 10;
const CROP_OVERLAY_EDGE_THICKNESS: i32 = 3;
const CROP_OVERLAY_OPACITY: u32 = 0xc400_0000;
const STOP_GRACE: Duration = Duration::from_secs(15);
const FORCE_KILL_GRACE: Duration = Duration::from_secs(5);
const DEFAULT_GTK_RENDERER: &str = "cairo";
const MAX_X11_WINDOW_SEARCH: usize = 4096;
const SELECTOR_VISUAL_EFFECT: VideoEffect = VideoEffect::Holo;
#[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,
}
struct RecorderInvocation {
program: PathBuf,
args: Vec<String>,
}
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,
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>>>,
preview_snapshot_failed: Rc<Cell<Option<SelectedArea>>>,
crop_overlay: Rc<RefCell<Option<CropOverlay>>>,
crop_overlay_failed: Rc<Cell<Option<SelectedArea>>>,
inline_crop_enabled: Rc<Cell<bool>>,
selector_open: Rc<Cell<bool>>,
x11_selector_available: bool,
child: Rc<RefCell<Option<Child>>>,
recording_started_at: Rc<RefCell<Option<Instant>>>,
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;
}
configure_gtk_startup();
let app = adw::Application::builder().application_id(APP_ID).build();
app.connect_activate(build_ui);
app.run()
}
fn configure_gtk_startup() {
if env::var_os("GDK_BACKEND").is_none() && env::var_os("DISPLAY").is_some() {
unsafe {
env::set_var("GDK_BACKEND", "x11");
}
}
if env::var_os("GSK_RENDERER").is_none() {
unsafe {
env::set_var("GSK_RENDERER", DEFAULT_GTK_RENDERER);
}
}
}
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(390)
.default_height(440)
.build();
window.add_css_class("ocula-window");
let toolbar = adw::ToolbarView::new();
let header = adw::HeaderBar::new();
header.add_css_class("ocula-header");
toolbar.add_top_bar(&header);
let root = gtk::Box::new(gtk::Orientation::Vertical, 7);
root.add_css_class("ocula-shell");
root.set_margin_top(10);
root.set_margin_bottom(10);
root.set_margin_start(10);
root.set_margin_end(10);
toolbar.set_content(Some(&root));
window.set_content(Some(&toolbar));
let controls = gtk::Box::new(gtk::Orientation::Vertical, 7);
controls.add_css_class("ocula-controls");
root.append(&controls);
let capture_card = card();
capture_card.add_css_class("ocula-capture-card");
capture_card.append(§ion_heading(
"Capture",
"Choose what appears in the recording",
));
controls.append(&capture_card);
let mode_box = gtk::Box::new(gtk::Orientation::Horizontal, 5);
mode_box.add_css_class("ocula-mode-strip");
let screen_button = mode_button("Screen", "video-display-symbolic");
let window_button = mode_button("Window", "window-new-symbolic");
let area_button = mode_button("Area", "selection-mode-symbolic");
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);
capture_card.append(&mode_box);
let area_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);
area_row.add_css_class("ocula-area-row");
area_row.set_valign(gtk::Align::Center);
let area_label = gtk::Label::new(Some("No frame selected"));
area_label.set_xalign(0.0);
area_label.set_hexpand(true);
area_label.set_ellipsize(pango::EllipsizeMode::End);
area_label.add_css_class("dim-label");
let select_area_button = gtk::Button::with_label("Frame Area");
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);
capture_card.append(&area_row);
let selected_area = Rc::new(RefCell::new(None));
let preview_snapshot = Rc::new(RefCell::new(None));
let preview_snapshot_failed = Rc::new(Cell::new(None));
let crop_overlay = Rc::new(RefCell::new(None));
let crop_overlay_failed = Rc::new(Cell::new(None));
let x11_selector_available = x11_selector_available();
let inline_crop_enabled = Rc::new(Cell::new(false));
let child = Rc::new(RefCell::new(None));
let area_preview = gtk::DrawingArea::builder()
.height_request(104)
.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_accessible_role(gtk::AccessibleRole::Button);
area_preview.update_property(&[gtk::accessible::Property::Label(
"Select or refine recording frame",
)]);
area_preview.set_visible(false);
capture_card.append(&area_preview);
let recording_card = card();
recording_card.add_css_class("ocula-recording-card");
recording_card.append(§ion_heading(
"Recording",
"Sound, format and destination",
));
controls.append(&recording_card);
let audio_dropdown =
gtk::DropDown::from_strings(&["No audio", "Mic", "System", "Mic + system"]);
audio_dropdown.set_hexpand(true);
audio_dropdown.update_property(&[gtk::accessible::Property::Label("Audio")]);
recording_card.append(&setting_row("Audio", &audio_dropdown));
let hide_pointer_switch = gtk::Switch::builder().valign(gtk::Align::Center).build();
hide_pointer_switch.update_property(&[gtk::accessible::Property::Label("Hide cursor")]);
recording_card.append(&setting_row("Hide cursor", &hide_pointer_switch));
let format_dropdown =
gtk::DropDown::from_strings(&["MKV - safer", "MP4 - compatible", "GIF - short clips"]);
format_dropdown.set_selected(1);
format_dropdown.set_hexpand(true);
format_dropdown.update_property(&[gtk::accessible::Property::Label("Format")]);
recording_card.append(&setting_row("Format", &format_dropdown));
let encoder_dropdown = gtk::DropDown::from_strings(&["Auto", "GPU H.264", "CPU H.264"]);
encoder_dropdown.set_hexpand(true);
encoder_dropdown.update_property(&[gtk::accessible::Property::Label("Encoder")]);
recording_card.append(&setting_row("Encoder", &encoder_dropdown));
install_area_preview_draw(
&area_preview,
Rc::clone(&selected_area),
Rc::clone(&preview_snapshot),
);
install_area_preview_animation(&area_preview, Rc::clone(&child));
let output_box = gtk::Box::new(gtk::Orientation::Horizontal, 6);
let output_entry = gtk::Entry::builder()
.hexpand(true)
.placeholder_text(default_output_hint(OutputFormatChoice::Mp4))
.build();
output_entry.update_property(&[gtk::accessible::Property::Label("Output file")]);
let browse_button = gtk::Button::from_icon_name("folder-open-symbolic");
browse_button.set_tooltip_text(Some("Choose output file"));
browse_button.update_property(&[gtk::accessible::Property::Label("Choose output file")]);
browse_button.add_css_class("ocula-file-button");
output_box.append(&output_entry);
output_box.append(&browse_button);
recording_card.append(&setting_row("Output", &output_box));
let action_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);
action_row.add_css_class("ocula-action-row");
action_row.add_css_class("ocula-action-dock");
let record_button = gtk::Button::with_label("Record");
record_button.set_hexpand(true);
record_button.add_css_class("ocula-record-button");
record_button.set_tooltip_text(Some("Start or stop recording (Ctrl+R)"));
record_button.update_property(&[gtk::accessible::Property::KeyShortcuts("Control+R")]);
action_row.append(&record_button);
let status_label = gtk::Label::new(Some("Ready"));
status_label.add_css_class("ocula-status");
status_label.set_accessible_role(gtk::AccessibleRole::Status);
status_label.set_halign(gtk::Align::Center);
status_label.set_width_chars(13);
status_label.set_max_width_chars(18);
status_label.set_ellipsize(pango::EllipsizeMode::End);
action_row.append(&status_label);
root.append(&action_row);
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(72)
.vexpand(true)
.child(&log_view)
.build();
let details = gtk::Expander::new(Some("Details"));
details.add_css_class("ocula-details");
details.set_child(Some(&log_scroller));
details.set_visible(false);
let details_for_buffer = details.clone();
log_buffer.connect_changed(move |buffer| {
if buffer.char_count() > 0 {
details_for_buffer.set_visible(true);
}
});
root.append(&details);
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,
output_entry,
record_button,
status_label,
log_buffer,
selected_area,
preview_snapshot,
preview_snapshot_failed,
crop_overlay,
crop_overlay_failed,
inline_crop_enabled,
selector_open: Rc::new(Cell::new(false)),
x11_selector_available,
child,
recording_started_at: Rc::new(RefCell::new(None)),
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_output_chooser(&ui, &browse_button);
connect_record_button(&ui);
connect_global_shortcuts(&ui);
connect_window_shutdown(&ui);
install_child_poll(&ui);
install_live_area_preview_refresh(&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: #0b0f14; }\n\
.ocula-header { min-height: 28px; padding: 0 7px; background: #0b0f14; box-shadow: none; border-bottom: 1px solid alpha(#9db2c3, 0.12); }\n\
.ocula-shell { background: #0b0f14; }\n\
.ocula-card { padding: 10px; border-radius: 12px; background: #121922; border: 1px solid alpha(#9db2c3, 0.16); box-shadow: 0 7px 20px rgba(0,0,0,0.20); }\n\
.ocula-controls.ocula-area-ready .ocula-capture-card { border-color: alpha(#45d6a5, 0.58); box-shadow: inset 3px 0 #45d6a5, 0 7px 20px rgba(0,0,0,0.20); }\n\
.ocula-controls.ocula-recording .ocula-card { border-color: alpha(#ff626c, 0.34); }\n\
.ocula-section-heading { margin-bottom: 2px; }\n\
.ocula-section-title { color: #edf5fa; font-size: 1.00em; font-weight: 800; letter-spacing: -0.01em; }\n\
.ocula-section-subtitle { color: alpha(#c8d6df, 0.58); font-size: 0.75em; }\n\
.ocula-mode-strip { padding: 3px; border-radius: 11px; background: #0a1016; border: 1px solid alpha(#9db2c3, 0.13); }\n\
.ocula-mode-button { min-height: 27px; padding: 3px 8px; border-radius: 8px; background: transparent; border: none; box-shadow: none; color: alpha(#d9e4eb, 0.68); font-size: 0.86em; font-weight: 720; }\n\
.ocula-mode-button:hover { color: #f4fbff; background: alpha(#9db2c3, 0.08); }\n\
.ocula-mode-button:checked { color: #061116; background: #55d7f2; box-shadow: none; }\n\
.ocula-setting-row { min-height: 27px; padding: 0; }\n\
.ocula-setting-label { color: alpha(#d9e4eb, 0.72); font-weight: 650; font-size: 0.84em; }\n\
.ocula-area-row { padding: 6px 7px; border-radius: 9px; background: #0d141c; border: 1px solid alpha(#9db2c3, 0.14); }\n\
.ocula-area-selected { color: #5be0b2; font-weight: 780; }\n\
.ocula-card button { min-height: 27px; padding: 4px 9px; border-radius: 8px; background: #19232e; border: 1px solid alpha(#9db2c3, 0.16); box-shadow: none; color: #edf5fa; font-weight: 680; }\n\
.ocula-card button:hover { background: #202d39; border-color: alpha(#8de8fa, 0.34); }\n\
.ocula-area-select-button { color: #071318; background: #55d7f2; border-color: #55d7f2; font-weight: 780; }\n\
.ocula-area-select-button:hover { background: #75e2f7; border-color: #75e2f7; }\n\
.ocula-area-select-button.ocula-crop-ready { color: #06130f; background: #5be0b2; border-color: #5be0b2; }\n\
.ocula-file-button { min-width: 30px; padding: 4px; }\n\
.ocula-card entry, .ocula-card dropdown button { border-radius: 9px; background: #0d141c; border: 1px solid alpha(#9db2c3, 0.15); box-shadow: none; }\n\
.ocula-area-preview { border-radius: 11px; background: #080d12; border: 1px solid alpha(#8de8fa, 0.20); box-shadow: inset 0 0 0 1px alpha(#000000, 0.28); }\n\
.ocula-area-preview:focus { border-color: #55d7f2; box-shadow: 0 0 0 2px alpha(#55d7f2, 0.22); }\n\
.ocula-action-dock { min-height: 38px; padding: 6px; border-radius: 12px; background: #101720; border: 1px solid alpha(#9db2c3, 0.16); box-shadow: 0 7px 20px rgba(0,0,0,0.22); }\n\
.ocula-record-button { min-height: 33px; padding: 6px 12px; border-radius: 9px; color: #061116; background: #55d7f2; border: 1px solid #55d7f2; box-shadow: none; font-weight: 840; letter-spacing: 0.02em; }\n\
.ocula-record-button:hover { background: #75e2f7; border-color: #75e2f7; box-shadow: none; }\n\
.ocula-record-button.ocula-recording { color: #ffffff; background: #e54854; border-color: #ff6670; box-shadow: none; }\n\
.ocula-status { padding: 5px 10px; border-radius: 999px; color: alpha(#d9e4eb, 0.82); background: #0b1118; border: 1px solid alpha(#9db2c3, 0.15); font-size: 0.80em; font-weight: 760; letter-spacing: 0.01em; }\n\
.ocula-status.ocula-recording { color: #ffdce0; background: #32151a; border-color: alpha(#ff626c, 0.52); }\n\
.ocula-card button:focus-visible, .ocula-record-button:focus-visible { outline: 2px solid #8de8fa; outline-offset: 2px; }\n\
.ocula-details { margin: 0 3px; color: alpha(#d9e4eb, 0.60); font-size: 0.82em; }\n\
.ocula-log { padding: 9px; font-size: 11px; color: alpha(#d9e4eb, 0.82); background: #080d12; border-radius: 10px; border: 1px solid alpha(#9db2c3, 0.12); }",
);
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, 7);
card.add_css_class("ocula-card");
card
}
fn section_heading(title: &str, subtitle: &str) -> gtk::Box {
let heading = gtk::Box::new(gtk::Orientation::Vertical, 1);
heading.add_css_class("ocula-section-heading");
let title = gtk::Label::new(Some(title));
title.set_xalign(0.0);
title.add_css_class("ocula-section-title");
heading.append(&title);
let subtitle = gtk::Label::new(Some(subtitle));
subtitle.set_xalign(0.0);
subtitle.set_wrap(true);
subtitle.add_css_class("ocula-section-subtitle");
heading.append(&subtitle);
heading
}
fn mode_button(text: &str, icon_name: &str) -> gtk::ToggleButton {
let content = gtk::Box::new(gtk::Orientation::Horizontal, 6);
content.set_halign(gtk::Align::Center);
let icon = gtk::Image::from_icon_name(icon_name);
icon.set_pixel_size(14);
let label = gtk::Label::new(Some(text));
content.append(&icon);
content.append(&label);
let button = gtk::ToggleButton::new();
button.set_child(Some(&content));
button.set_hexpand(true);
button.update_property(&[gtk::accessible::Property::Label(text)]);
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, 8);
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>>>,
) {
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(),
0,
started_at.elapsed().as_secs_f64(),
);
});
}
fn install_area_preview_animation(preview: >k::DrawingArea, child: Rc<RefCell<Option<Child>>>) {
let settings = gtk::Settings::default();
preview.add_tick_callback(move |preview, _| {
let animations_enabled = settings
.as_ref()
.is_none_or(gtk::Settings::is_gtk_enable_animations);
if animations_enabled
&& preview.is_visible()
&& preview.is_mapped()
&& child.borrow().is_none()
{
preview.queue_draw();
}
glib::ControlFlow::Continue
});
}
fn install_live_area_preview_refresh(ui: &Ui) {
let ui = ui.clone();
glib::timeout_add_local(LIVE_PREVIEW_REFRESH, move || {
refresh_live_area_preview(&ui);
glib::ControlFlow::Continue
});
}
fn refresh_live_area_preview(ui: &Ui) {
if selected_mode(ui) != Mode::Area
|| !ui.area_preview.is_visible()
|| !ui.area_preview.is_mapped()
|| ui.child.borrow().is_some()
|| !ui.x11_selector_available
{
return;
}
let Some(selected) = *ui.selected_area.borrow() else {
ui.preview_snapshot_failed.set(None);
return;
};
if ui.preview_snapshot_failed.get() == Some(selected) {
return;
}
match capture_area_snapshot(selected) {
Ok(snapshot) => {
ui.preview_snapshot_failed.set(None);
ui.preview_snapshot.replace(Some(snapshot));
ui.area_preview.queue_draw();
}
Err(err) => {
ui.preview_snapshot_failed.set(Some(selected));
ui.preview_snapshot.replace(None);
ui.area_preview.queue_draw();
append_log(
&ui.log_buffer,
&format!("Live area preview refresh unavailable: {err:#}"),
);
}
}
}
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.025, 0.045, 0.065, 1.0);
bg.add_color_stop_rgba(1.0, 0.055, 0.085, 0.11, 1.0);
cr.rectangle(0.0, 0.0, width, height);
let _ = cr.set_source(&bg);
let _ = cr.fill();
draw_preview_grid(cr, width, height);
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);
}
if effect != 0 {
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 {} | {}, {}",
selected.area.width, selected.area.height, selected.area.x, selected.area.y
),
style.primary,
);
let effect_label = preview_effect_chip_text(style);
if effect != 0 && 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 {
"Click to refine frame"
} else {
"Live preview 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: "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: "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: "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: "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: "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: "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: "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: "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: "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: "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: "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: "Kaleidoscope",
primary: (0.00, 0.76, 1.00),
secondary: (1.00, 0.18, 0.84),
tertiary: (0.18, 1.00, 0.54),
},
_ => PreviewEffectStyle {
chip: "No effect",
primary: (0.33, 0.84, 0.95),
secondary: (0.58, 0.68, 0.75),
tertiary: (0.78, 0.88, 0.93),
},
}
}
fn draw_preview_grid(cr: >k::cairo::Context, width: f64, height: f64) {
cr.set_line_width(1.0);
cr.set_source_rgba(0.62, 0.72, 0.78, 0.055);
let mut x = 24.0;
while x < width {
cr.move_to(x, 0.0);
cr.line_to(x, height);
x += 24.0;
}
let mut y = 24.0;
while y < height {
cr.move_to(0.0, y);
cr.line_to(width, y);
y += 24.0;
}
let _ = cr.stroke();
}
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_border(cr, rect, phase, style);
draw_preview_chip(
cr,
rect.x + 14.0,
rect.y + rect.height / 2.0 - 12.0,
"Frame an area to preview",
style.tertiary,
);
draw_preview_chip(cr, 14.0, 14.0, "Area frame", style.primary);
}
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"#55d7f2",
screen.white_pixel,
);
let edge_pixel = x11_color_pixel(
&conn,
screen.default_colormap,
b"#f4fbff",
screen.white_pixel,
);
let stack_below = x11_process_root_window(&conn, screen.root, std::process::id())?;
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 Recording Frame Glow",
stack_below,
)?);
if !edge_rectangles.is_empty() {
windows.push(create_crop_overlay_window(
&conn,
screen,
opacity_atom,
&edge_rectangles,
edge_pixel,
b"Ocula Recording Frame Edge",
stack_below,
)?);
}
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],
stack_below: Option<Window>,
) -> 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()?;
let stack = match stack_below {
Some(sibling) => ConfigureWindowAux::new()
.sibling(sibling)
.stack_mode(StackMode::BELOW),
None => ConfigureWindowAux::new().stack_mode(StackMode::ABOVE),
};
conn.configure_window(window, &stack)?.check()?;
Ok(())
})();
if let Err(err) = result {
destroy_crop_overlay_windows(conn, &[window]);
return Err(err);
}
Ok(window)
}
fn x11_process_root_window(
conn: &RustConnection,
root: Window,
process_id: u32,
) -> Result<Option<Window>> {
let pid_atom = conn.intern_atom(false, b"_NET_WM_PID")?.reply()?.atom;
let children = conn.query_tree(root)?.reply()?.children;
let mut pending = children
.into_iter()
.map(|window| (window, window))
.collect::<Vec<_>>();
let mut inspected = 0_usize;
while let Some((window, root_child)) = pending.pop() {
inspected += 1;
if inspected > MAX_X11_WINDOW_SEARCH {
break;
}
let owner = conn
.get_property(false, window, pid_atom, AtomEnum::CARDINAL, 0, 1)
.ok()
.and_then(|cookie| cookie.reply().ok())
.and_then(|reply| reply.value32().and_then(|mut values| values.next()));
if owner == Some(process_id) {
return Ok(Some(root_child));
}
if let Ok(cookie) = conn.query_tree(window)
&& let Ok(tree) = cookie.reply()
{
pending.extend(tree.children.into_iter().map(|child| (child, root_child)));
}
}
Ok(None)
}
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);
if !ui_for_area.x11_selector_available {
ui_for_area.status_label.set_text("Framing unavailable");
}
}
});
}
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 drag = gtk::GestureDrag::new();
let drag_start = Rc::new(Cell::new(None));
let ui_for_drag_begin = ui.clone();
let drag_start_begin = Rc::clone(&drag_start);
drag.connect_drag_begin(move |_, start_x, start_y| {
if area_preview_can_inline_crop(&ui_for_drag_begin) {
drag_start_begin.set(Some((start_x, start_y)));
ui_for_drag_begin.status_label.set_text("Drag to select");
}
});
let ui_for_drag_update = ui.clone();
let drag_start_update = Rc::clone(&drag_start);
drag.connect_drag_update(move |_, offset_x, offset_y| {
if let Some(start) = drag_start_update.get() {
update_inline_crop_from_preview(
&ui_for_drag_update,
start,
(start.0 + offset_x, start.1 + offset_y),
);
}
});
let ui_for_drag_end = ui.clone();
let drag_start_end = Rc::clone(&drag_start);
drag.connect_drag_end(move |_, offset_x, offset_y| {
if let Some(start) = drag_start_end.take() {
update_inline_crop_from_preview(
&ui_for_drag_end,
start,
(start.0 + offset_x, start.1 + offset_y),
);
}
});
ui.area_preview.add_controller(drag);
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() && ui.x11_selector_available
}
fn area_preview_can_inline_crop(ui: &Ui) -> bool {
area_preview_can_open_selector(ui) && ui.inline_crop_enabled.get()
}
fn open_area_selector(ui: &Ui) {
if !ui.x11_selector_available {
ui.status_label.set_text("Framing unavailable");
append_log(
&ui.log_buffer,
"Area selection requires an available X11 or XWayland display",
);
return;
}
if ui.selector_open.replace(true) {
return;
}
ui.window.set_visible(false);
let ui = ui.clone();
glib::idle_add_local_once(move || run_area_selector(&ui));
}
fn run_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 frame, then release");
append_log(
&ui.log_buffer,
&format!(
"Opening adjustable recording frame at {}",
area_label(selected)
),
);
} else {
ui.status_label.set_text("Drag to frame the recording");
append_log(&ui.log_buffer, "Opening recording frame selector");
}
let selection = if let Some(selected) = previous {
selector::adjust_area_once_with_effect(selected.area, SELECTOR_VISUAL_EFFECT)
} else {
selector::select_area_once_with_effect(SELECTOR_VISUAL_EFFECT)
};
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_failed.set(None);
ui.preview_snapshot.replace(Some(snapshot));
append_log(&ui.log_buffer, "Captured selected-area preview");
}
Err(err) => {
ui.preview_snapshot_failed.set(Some(selected));
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("Frame ready");
append_log(
&ui.log_buffer,
&format!("Selected {}", area_label(selected)),
);
}
Err(err) => {
if area_selection_was_cancelled(&err) {
ui.status_label.set_text("Selection canceled");
append_log(&ui.log_buffer, "Recording frame selection cancelled");
} else {
ui.inline_crop_enabled.set(false);
append_log(
&ui.log_buffer,
&format!("Recording frame selector unavailable: {err:#}"),
);
ui.status_label.set_text("Framing unavailable");
}
}
}
ui.selector_open.set(false);
update_area_controls(ui);
ui.window.present();
}
fn update_inline_crop_from_preview(ui: &Ui, start: (f64, f64), end: (f64, f64)) {
let Some((screen_width, screen_height)) = preview_fallback_screen_size(ui) else {
ui.status_label.set_text("Selection failed");
return;
};
let Some(selected) = selected_area_from_preview_drag(
start,
end,
ui.area_preview.width(),
ui.area_preview.height(),
screen_width,
screen_height,
) else {
return;
};
set_inline_crop(ui, selected);
ui.status_label.set_text("Frame ready");
}
fn set_inline_crop(ui: &Ui, selected: SelectedArea) {
ui.preview_snapshot.replace(None);
ui.preview_snapshot_failed.set(Some(selected));
ui.selected_area.replace(Some(selected));
ui.area_preview.queue_draw();
update_area_controls(ui);
}
fn preview_fallback_screen_size(ui: &Ui) -> Option<(i32, i32)> {
let display = adw::prelude::RootExt::display(&ui.window);
if let Some(surface) = ui.window.surface()
&& let Some(monitor) = display.monitor_at_surface(&surface)
&& let Some(size) = monitor_size(&monitor)
{
return Some(size);
}
let monitors = display.monitors();
for index in 0..monitors.n_items() {
let Some(item) = monitors.item(index) else {
continue;
};
let Ok(monitor) = item.downcast::<gtk::gdk::Monitor>() else {
continue;
};
if let Some(size) = monitor_size(&monitor) {
return Some(size);
}
}
None
}
fn monitor_size(monitor: >k::gdk::Monitor) -> Option<(i32, i32)> {
let geometry = monitor.geometry();
(geometry.width() > 0 && geometry.height() > 0).then_some((geometry.width(), geometry.height()))
}
#[cfg(test)]
fn default_inline_crop(screen_width: i32, screen_height: i32) -> SelectedArea {
let width = (screen_width * 3 / 4).max(1);
let height = (screen_height * 3 / 4).max(1);
let x = (screen_width - width).max(0) / 2;
let y = (screen_height - height).max(0) / 2;
SelectedArea {
area: CaptureArea::new(x, y, width, height).unwrap_or(CaptureArea {
x: 0,
y: 0,
width: screen_width.max(1),
height: screen_height.max(1),
}),
screen_width,
screen_height,
}
}
fn selected_area_from_preview_drag(
start: (f64, f64),
end: (f64, f64),
preview_width: i32,
preview_height: i32,
screen_width: i32,
screen_height: i32,
) -> Option<SelectedArea> {
if preview_width <= 1 || preview_height <= 1 || screen_width <= 0 || screen_height <= 0 {
return None;
}
let preview_width = f64::from(preview_width);
let preview_height = f64::from(preview_height);
let scale =
(preview_width / f64::from(screen_width)).min(preview_height / f64::from(screen_height));
if scale <= 0.0 {
return None;
}
let map_width = f64::from(screen_width) * scale;
let map_height = f64::from(screen_height) * scale;
let origin_x = (preview_width - map_width) / 2.0;
let origin_y = (preview_height - map_height) / 2.0;
let to_screen = |point: (f64, f64)| {
let x = ((point.0 - origin_x).clamp(0.0, map_width) / scale).round() as i32;
let y = ((point.1 - origin_y).clamp(0.0, map_height) / scale).round() as i32;
(x.clamp(0, screen_width), y.clamp(0, screen_height))
};
let start = to_screen(start);
let end = to_screen(end);
let left = start.0.min(end.0);
let top = start.1.min(end.1);
let right = start.0.max(end.0);
let bottom = start.1.max(end.1);
let width = right - left;
let height = bottom - top;
if width < 4 || height < 4 {
return None;
}
CaptureArea::new(left, top, width, height)
.ok()
.map(|area| SelectedArea {
area,
screen_width,
screen_height,
})
}
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 Frame");
ui.select_area_button.add_css_class("ocula-crop-ready");
ui.area_preview.queue_draw();
} else {
ui.area_label.set_text("No frame selected");
ui.area_label.add_css_class("dim-label");
ui.area_label.remove_css_class("ocula-area-selected");
ui.select_area_button.set_label("Frame Area");
ui.select_area_button.remove_css_class("ocula-crop-ready");
}
let preview_tooltip = if is_recording {
"Frame controls are locked while recording"
} else if has_area {
"Click or press Enter to refine the recording frame"
} else {
"Click or press Enter to frame a recording area"
};
ui.area_preview.set_tooltip_text(Some(preview_tooltip));
ui.area_preview
.set_cursor_from_name(area_preview_cursor_name(is_recording));
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");
ui.status_label.remove_css_class("ocula-recording");
ui.controls_card.set_sensitive(!is_recording);
if is_recording {
ui.controls_card.add_css_class("ocula-recording");
ui.record_button.add_css_class("ocula-recording");
ui.status_label.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(ui.x11_selector_available);
ui.area_preview.set_sensitive(ui.x11_selector_available);
} else {
ui.record_button.set_sensitive(true);
ui.select_area_button.set_sensitive(false);
ui.area_preview.set_sensitive(false);
}
}
fn sync_crop_overlay(ui: &Ui) {
let desired = if selected_mode(ui) == Mode::Area {
*ui.selected_area.borrow()
} else {
None
};
if ui.inline_crop_enabled.get() || !ui.x11_selector_available {
ui.crop_overlay.borrow_mut().take();
ui.crop_overlay_failed.set(None);
return;
}
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!("Area outline unavailable: {err:#}"),
);
}
}
} else {
ui.crop_overlay.borrow_mut().take();
ui.crop_overlay_failed.set(None);
}
}
fn area_preview_cursor_name(is_recording: bool) -> Option<&'static str> {
if is_recording { None } else { Some("pointer") }
}
fn x11_selector_available() -> bool {
let has_display = env::var_os("DISPLAY").is_some();
let can_connect_x11 = has_display && connect(None).is_ok();
x11_selector_available_for(has_display, can_connect_x11)
}
fn x11_selector_available_for(has_display: bool, can_connect_x11: bool) -> bool {
has_display && can_connect_x11
}
fn area_selection_was_cancelled(err: &anyhow::Error) -> bool {
err.chain()
.any(|cause| cause.to_string() == "area selection cancelled")
}
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 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: video only");
} 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 |_| {
toggle_recording(&ui);
});
}
fn connect_global_shortcuts(ui: &Ui) {
let key = gtk::EventControllerKey::new();
let window = ui.window.clone();
let ui = ui.clone();
key.connect_key_pressed(move |_, key, _, modifiers| {
if modifiers.contains(gtk::gdk::ModifierType::CONTROL_MASK)
&& matches!(key, gtk::gdk::Key::r | gtk::gdk::Key::R)
{
toggle_recording(&ui);
return glib::Propagation::Stop;
}
glib::Propagation::Proceed
});
window.add_controller(key);
}
fn toggle_recording(ui: &Ui) {
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();
ui.recording_started_at.replace(None);
reset_stop_state(&ui);
reset_record_button(&ui);
match result {
Ok(exit) if exit.success() => ui.status_label.set_text("Saved"),
Ok(exit) => {
ui.status_label.set_text("Recording failed");
append_log(&ui.log_buffer, &format!("Recorder exited with {exit}"));
}
Err(err) => {
ui.status_label.set_text("Status check failed");
append_log(
&ui.log_buffer,
&format!("Failed to inspect recorder: {err}"),
);
}
}
update_area_controls(&ui);
if ui.close_after_child_exit.replace(false) {
ui.window.close();
}
} else if ui.child.borrow().is_some() {
if ui.stop_started_at.borrow().is_none() {
update_recording_timer(&ui);
}
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("Frame an area");
append_log(&ui.log_buffer, "Area mode requires selecting an area first");
update_area_controls(ui);
return;
}
normalize_output_extension(ui);
if let Err(err) = build_recorder_if_stale(ui) {
ui.status_label.set_text("Setup failed");
append_log(&ui.log_buffer, &format!("Recorder build failed: {err:#}"));
update_area_controls(ui);
return;
}
let args = recorder_args(ui);
let invocation = recorder_invocation(args);
append_log(
&ui.log_buffer,
&format!("Starting: {}", invocation_label(&invocation)),
);
let mut command = Command::new(&invocation.program);
command
.args(&invocation.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));
ui.recording_started_at.replace(Some(Instant::now()));
reset_stop_state(ui);
ui.close_after_child_exit.set(false);
update_recording_timer(ui);
ui.record_button.set_label("Stop");
ui.record_button.set_sensitive(true);
update_area_controls(ui);
}
Err(err) => {
let message = format!(
"Could not start recorder `{}`: {err}",
invocation.program.display()
);
ui.status_label.set_text("Could not start");
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);
ui.status_label.set_text("Saving...");
ui.record_button.set_label("Force Stop");
ui.record_button
.set_tooltip_text(Some("Use only if saving does not finish"));
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 stopping...");
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("Force-stopped");
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("Record");
ui.record_button
.set_tooltip_text(Some("Start or stop recording (Ctrl+R)"));
ui.record_button.remove_css_class("suggested-action");
ui.record_button.remove_css_class("destructive-action");
}
fn update_recording_timer(ui: &Ui) {
let Some(started_at) = *ui.recording_started_at.borrow() else {
return;
};
ui.status_label.set_text(&format!(
"REC {}",
format_recording_elapsed(started_at.elapsed())
));
}
fn format_recording_elapsed(elapsed: Duration) -> String {
let seconds = elapsed.as_secs();
format!("{:02}:{:02}", seconds / 60, seconds % 60)
}
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 let Some(backend) = area_backend_arg(mode) {
args.push("--backend".to_string());
args.push(backend.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());
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_backend_arg(_mode: Mode) -> Option<&'static str> {
None
}
fn area_label(selected: SelectedArea) -> String {
format!(
"{} x {} | x {}, y {}",
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",
}
}
#[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 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 recorder_invocation(args: Vec<String>) -> RecorderInvocation {
if let Ok(current) = env::current_exe() {
return recorder_invocation_for_current(¤t, args);
}
direct_recorder_invocation(PathBuf::from("oculo"), args)
}
fn recorder_invocation_for_current(current: &Path, args: Vec<String>) -> RecorderInvocation {
if let Some(candidate) = sibling_recorder_path(current)
&& candidate.exists()
{
return direct_recorder_invocation(candidate, args);
}
direct_recorder_invocation(PathBuf::from("oculo"), args)
}
fn direct_recorder_invocation(program: PathBuf, args: Vec<String>) -> RecorderInvocation {
RecorderInvocation { program, args }
}
const RECORDER_SOURCE_INPUTS: &[&str] = &[
"Cargo.toml",
"Cargo.lock",
"src/main.rs",
"src/lib.rs",
"src/monitor.rs",
"src/output.rs",
"src/pipeline.rs",
"src/portal.rs",
"src/selector.rs",
];
fn sibling_recorder_is_stale(current: &Path, recorder: &Path) -> bool {
let Ok(recorder_modified) = recorder.metadata().and_then(|metadata| metadata.modified()) else {
return false;
};
let Some(root) = cargo_project_root_for_target_exe(current) else {
return false;
};
RECORDER_SOURCE_INPUTS.iter().any(|path| {
root.join(path)
.metadata()
.and_then(|metadata| metadata.modified())
.is_ok_and(|modified| modified > recorder_modified)
})
}
fn cargo_project_root_for_target_exe(current: &Path) -> Option<PathBuf> {
let profile_dir = current.parent()?;
let target_dir = profile_dir.parent()?;
if target_dir.file_name()? != "target" {
return None;
}
Some(target_dir.parent()?.to_path_buf())
}
fn build_recorder_if_stale(ui: &Ui) -> Result<()> {
let current = env::current_exe().context("failed to resolve GUI executable")?;
let Some(recorder) = sibling_recorder_path(¤t) else {
return Ok(());
};
if recorder.exists() && !sibling_recorder_is_stale(¤t, &recorder) {
return Ok(());
}
let Some(root) = cargo_project_root_for_target_exe(¤t) else {
return Ok(());
};
ui.status_label.set_text("Preparing...");
append_log(
&ui.log_buffer,
"Recorder binary is stale; building updated oculo binary",
);
while glib::MainContext::default().iteration(false) {}
let build_args = recorder_build_args(¤t);
let status = Command::new("cargo")
.args(&build_args)
.current_dir(&root)
.status()
.with_context(|| format!("failed to run cargo build in {}", root.display()))?;
if !status.success() {
bail!("cargo {} exited with {status}", build_args.join(" "));
}
ensure!(
recorder.exists(),
"cargo build succeeded but `{}` does not exist",
recorder.display()
);
Ok(())
}
fn recorder_build_args(current: &Path) -> Vec<&'static str> {
let mut args = vec!["build", "--quiet"];
if current
.parent()
.and_then(Path::file_name)
.is_some_and(|profile| profile == "release")
{
args.push("--release");
}
args.extend(["--bin", "oculo"]);
args
}
fn invocation_label(invocation: &RecorderInvocation) -> String {
let mut label = invocation.program.display().to_string();
for arg in &invocation.args {
label.push(' ');
label.push_str(arg);
}
label
}
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 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 target_exe_maps_to_cargo_project_root() {
assert_eq!(
cargo_project_root_for_target_exe(Path::new("/repo/target/debug/ocula")).unwrap(),
PathBuf::from("/repo"),
);
assert!(cargo_project_root_for_target_exe(Path::new("/tmp/ocula")).is_none());
}
#[test]
fn recorder_build_matches_gui_profile() {
assert_eq!(
recorder_build_args(Path::new("/repo/target/debug/ocula")),
["build", "--quiet", "--bin", "oculo"],
);
assert_eq!(
recorder_build_args(Path::new("/repo/target/release/ocula")),
["build", "--quiet", "--release", "--bin", "oculo"],
);
}
#[test]
fn stale_dev_recorder_still_uses_direct_sibling_after_prebuild_step() {
let root = temp_project_dir("stale-recorder");
let debug_dir = root.join("target/debug");
std::fs::create_dir_all(&debug_dir).unwrap();
let gui = debug_dir.join("ocula");
let recorder = debug_dir.join("oculo");
std::fs::write(&recorder, b"old recorder").unwrap();
std::thread::sleep(Duration::from_millis(20));
std::fs::write(&gui, b"new gui").unwrap();
let invocation =
recorder_invocation_for_current(&gui, vec!["--mode".into(), "area".into()]);
assert_eq!(invocation.program, recorder);
assert_eq!(invocation.args, ["--mode", "area"]);
std::fs::remove_dir_all(root).unwrap();
}
#[test]
fn recorder_staleness_tracks_recorder_sources_not_gui_binary() {
let root = temp_project_dir("recorder-source-stale");
let debug_dir = root.join("target/debug");
let src_dir = root.join("src");
std::fs::create_dir_all(&debug_dir).unwrap();
std::fs::create_dir_all(&src_dir).unwrap();
let gui = debug_dir.join("ocula");
let recorder = debug_dir.join("oculo");
std::fs::write(&recorder, b"recorder").unwrap();
std::thread::sleep(Duration::from_millis(20));
std::fs::write(&gui, b"new gui").unwrap();
assert!(!sibling_recorder_is_stale(&gui, &recorder));
std::thread::sleep(Duration::from_millis(20));
std::fs::write(src_dir.join("main.rs"), b"new recorder source").unwrap();
assert!(sibling_recorder_is_stale(&gui, &recorder));
std::fs::remove_dir_all(root).unwrap();
}
#[test]
fn fresh_dev_recorder_uses_direct_sibling() {
let root = temp_project_dir("fresh-recorder");
let debug_dir = root.join("target/debug");
std::fs::create_dir_all(&debug_dir).unwrap();
let gui = debug_dir.join("ocula");
let recorder = debug_dir.join("oculo");
std::fs::write(&gui, b"old gui").unwrap();
std::thread::sleep(Duration::from_millis(20));
std::fs::write(&recorder, b"new recorder").unwrap();
let invocation =
recorder_invocation_for_current(&gui, vec!["--mode".into(), "area".into()]);
assert_eq!(invocation.program, recorder);
assert_eq!(invocation.args, ["--mode", "area"]);
std::fs::remove_dir_all(root).unwrap();
}
#[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), "160 x 120 | x 10, y 20");
}
#[test]
fn area_preview_cursor_matches_locked_state() {
assert_eq!(area_preview_cursor_name(false), Some("pointer"));
assert_eq!(area_preview_cursor_name(true), None);
}
#[test]
fn area_selection_cancellation_is_not_treated_as_failure() {
assert!(area_selection_was_cancelled(&anyhow::anyhow!(
"area selection cancelled"
)));
assert!(!area_selection_was_cancelled(&anyhow::anyhow!(
"failed to grab pointer"
)));
}
#[test]
fn x11_selector_availability_uses_reachable_display() {
assert!(x11_selector_available_for(true, true));
assert!(!x11_selector_available_for(true, false));
assert!(!x11_selector_available_for(false, true));
}
#[test]
fn area_backend_matches_crop_selector_route() {
assert_eq!(area_backend_arg(Mode::Area), None);
assert_eq!(area_backend_arg(Mode::Screen), None);
assert_eq!(area_backend_arg(Mode::Window), None);
}
#[test]
fn recording_elapsed_formats_as_minutes_and_seconds() {
assert_eq!(format_recording_elapsed(Duration::from_secs(0)), "00:00");
assert_eq!(format_recording_elapsed(Duration::from_secs(65)), "01:05");
assert_eq!(
format_recording_elapsed(Duration::from_secs(7_503)),
"125:03"
);
}
#[test]
fn preview_drag_maps_to_screen_crop() {
let selected =
selected_area_from_preview_drag((80.0, 40.0), (240.0, 160.0), 320, 180, 1600, 900)
.unwrap();
assert_eq!(selected.screen_width, 1600);
assert_eq!(selected.screen_height, 900);
assert_eq!(selected.area, CaptureArea::new(400, 200, 800, 600).unwrap());
}
#[test]
fn preview_drag_uses_centered_letterbox_map() {
let selected =
selected_area_from_preview_drag((80.0, 30.0), (240.0, 130.0), 320, 180, 1600, 800)
.unwrap();
assert_eq!(selected.area, CaptureArea::new(400, 100, 800, 500).unwrap());
}
#[test]
fn default_inline_crop_is_centered() {
let selected = default_inline_crop(1600, 900);
assert_eq!(
selected.area,
CaptureArea::new(200, 112, 1200, 675).unwrap()
);
assert_eq!(selected.screen_width, 1600);
assert_eq!(selected.screen_height, 900);
}
#[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 temp_project_dir(label: &str) -> PathBuf {
let unique = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
std::env::temp_dir().join(format!("ocula-{label}-{}-{unique}", std::process::id()))
}
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
})
}
}