use std::{
cell::{Cell, RefCell},
env,
f64::consts::{FRAC_PI_2, PI},
fs,
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 anyhow::{Context, Result, bail, ensure};
use gtk::prelude::*;
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 APP_ICON_NAME: &str = "dev.ocula.Ocula";
const APP_ICON_FILE_NAME: &str = "dev.ocula.Ocula.png";
const APP_LOGO_BYTES: &[u8] = include_bytes!("../../assets/logo.png");
const HICOLOR_INDEX_THEME: &str = "[Icon Theme]\nName=Hicolor\nComment=Fallback icon theme\nDirectories=256x256/apps\n\n[256x256/apps]\nSize=256\nContext=Applications\nType=Fixed\n";
const EXPANDED_WINDOW_WIDTH: i32 = 410;
const EXPANDED_WINDOW_HEIGHT: i32 = 540;
const COLLAPSED_WINDOW_WIDTH: i32 = 252;
const COLLAPSED_WINDOW_HEIGHT: i32 = 100;
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 SCREENSHOT_CAPTURE_MAX_PIXELS: i64 = 67_108_864;
const PREVIEW_ANIMATION_FRAME: Duration = Duration::from_millis(100);
const PREVIEW_ANIMATION_DURATION: Duration = Duration::from_millis(1800);
const PREVIEW_CAPTURE_TIMEOUT: Duration = Duration::from_secs(5);
const UI_POLL_INTERVAL: Duration = Duration::from_millis(200);
const CROP_OVERLAY_THICKNESS: i32 = 6;
const CROP_OVERLAY_EDGE_THICKNESS: i32 = 2;
const CROP_OVERLAY_GLOW_OPACITY: u32 = 0x4000_0000;
const CROP_OVERLAY_EDGE_OPACITY: u32 = 0x9000_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;
#[cfg(debug_assertions)]
const START_COLLAPSED_ENV: &str = "OCULA_START_COLLAPSED";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Mode {
Screen,
Window,
Area,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ScreenshotTarget {
Screen,
Area(SelectedArea),
Window(u32),
}
#[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,
}
struct PreviewImage {
data: Vec<u8>,
width: i32,
height: i32,
stride: i32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct PreviewRequest {
generation: u64,
selected: SelectedArea,
}
type PreviewCaptureResult = (PreviewRequest, Result<PreviewImage>);
type ScreenshotResult = Result<PathBuf, String>;
#[derive(Debug, Clone, Copy)]
struct PreviewStyle {
primary: (f64, f64, f64),
secondary: (f64, f64, f64),
tertiary: (f64, f64, f64),
}
const PREVIEW_STYLE: PreviewStyle = PreviewStyle {
primary: (0.31, 0.90, 1.00),
secondary: (0.12, 0.58, 0.70),
tertiary: (0.73, 0.92, 0.96),
};
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: gtk::ApplicationWindow,
root: gtk::Box,
brand: gtk::Box,
content_scroller: gtk::ScrolledWindow,
action_row: gtk::Box,
compact_bar: gtk::Box,
header_version: gtk::Label,
capture_kind_button: gtk::ToggleButton,
collapse_button: gtk::ToggleButton,
compact_screen_button: gtk::ToggleButton,
compact_window_button: gtk::ToggleButton,
compact_area_button: gtk::ToggleButton,
compact_record_button: gtk::Button,
compact_status: gtk::Label,
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>>>,
selection_mode: Rc<Cell<Option<Mode>>>,
selected_window_xid: Rc<Cell<Option<u32>>>,
preview_snapshot: Rc<RefCell<Option<PreviewSnapshot>>>,
preview_snapshot_failed: Rc<Cell<Option<SelectedArea>>>,
preview_animation_source: Rc<RefCell<Option<glib::SourceId>>>,
preview_generation: Rc<Cell<u64>>,
preview_capture_in_flight: Rc<Cell<Option<(PreviewRequest, Instant)>>>,
preview_capture_pending: Rc<Cell<Option<PreviewRequest>>>,
preview_tx: mpsc::SyncSender<PreviewCaptureResult>,
preview_rx: Rc<RefCell<mpsc::Receiver<PreviewCaptureResult>>>,
crop_overlay: Rc<RefCell<Option<CropOverlay>>>,
crop_overlay_failed: Rc<Cell<Option<SelectedArea>>>,
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>>,
screenshot_in_flight: Rc<Cell<bool>>,
screenshot_tx: mpsc::SyncSender<ScreenshotResult>,
screenshot_rx: Rc<RefCell<mpsc::Receiver<ScreenshotResult>>>,
collapsed: Rc<Cell<bool>>,
expanded_size: Rc<Cell<(i32, i32)>>,
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 = gtk::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 install_app_icon() {
gtk::Window::set_default_icon_name(APP_ICON_NAME);
let Some(display) = gtk::gdk::Display::default() else {
return;
};
match ensure_user_icon() {
Ok(icons_root) => gtk::IconTheme::for_display(&display).add_search_path(icons_root),
Err(err) => eprintln!("warning: could not install Ocula icon: {err:#}"),
}
}
fn ensure_user_icon() -> Result<PathBuf> {
let data_home = if let Some(path) = env::var_os("XDG_DATA_HOME").filter(|path| !path.is_empty())
{
PathBuf::from(path)
} else {
PathBuf::from(env::var_os("HOME").context("HOME or XDG_DATA_HOME is required")?)
.join(".local/share")
};
let icons_root = data_home.join("icons");
let theme_root = icons_root.join("hicolor");
let icon_file = app_icon_path(&data_home);
let mut changed = false;
fs::create_dir_all(
icon_file
.parent()
.context("application icon path has no parent")?,
)?;
let index_file = theme_root.join("index.theme");
if !index_file.exists() {
fs::write(&index_file, HICOLOR_INDEX_THEME)?;
changed = true;
}
if !matches!(fs::read(&icon_file), Ok(contents) if contents == APP_LOGO_BYTES) {
fs::write(&icon_file, APP_LOGO_BYTES)?;
changed = true;
}
if changed {
let _ = Command::new("gtk-update-icon-cache")
.args(["-q", "-t", "-f"])
.arg(&theme_root)
.stdout(Stdio::null())
.stderr(Stdio::null())
.status();
}
Ok(icons_root)
}
fn app_icon_path(data_home: &Path) -> PathBuf {
data_home
.join("icons/hicolor/256x256/apps")
.join(APP_ICON_FILE_NAME)
}
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: >k::Application) {
install_app_icon();
install_css();
let window = gtk::ApplicationWindow::builder()
.application(app)
.title("Ocula")
.icon_name(APP_ICON_NAME)
.default_width(EXPANDED_WINDOW_WIDTH)
.default_height(EXPANDED_WINDOW_HEIGHT)
.build();
window.add_css_class("ocula-window");
let header = gtk::HeaderBar::new();
header.add_css_class("ocula-header");
let header_title = gtk::Box::new(gtk::Orientation::Horizontal, 6);
let header_dot = gtk::Label::new(Some("●"));
header_dot.add_css_class("ocula-header-dot");
let header_name = gtk::Label::new(Some("OCULA"));
header_name.add_css_class("ocula-header-name");
header_title.append(&header_dot);
header_title.append(&header_name);
header.set_title_widget(Some(&header_title));
let version = gtk::Label::new(Some(concat!("v", env!("CARGO_PKG_VERSION"))));
version.add_css_class("ocula-header-version");
header.pack_end(&version);
let capture_kind_button = gtk::ToggleButton::new();
capture_kind_button.set_icon_name("camera-video-symbolic");
capture_kind_button.set_tooltip_text(Some("Switch to screenshot mode"));
capture_kind_button.add_css_class("flat");
capture_kind_button.add_css_class("ocula-capture-kind-button");
header.pack_end(&capture_kind_button);
let collapse_button = gtk::ToggleButton::new();
collapse_button.set_icon_name("view-restore-symbolic");
collapse_button.set_tooltip_text(Some("Collapse to mini controls"));
collapse_button.add_css_class("flat");
collapse_button.add_css_class("ocula-collapse-button");
header.pack_end(&collapse_button);
window.set_titlebar(Some(&header));
let root = gtk::Box::new(gtk::Orientation::Vertical, 8);
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);
window.set_child(Some(&root));
let brand = brand_panel();
root.append(&brand);
let controls = gtk::Box::new(gtk::Orientation::Vertical, 7);
controls.add_css_class("ocula-controls");
let content_scroller = gtk::ScrolledWindow::builder()
.hscrollbar_policy(gtk::PolicyType::Never)
.vscrollbar_policy(gtk::PolicyType::Automatic)
.hexpand(true)
.vexpand(true)
.child(&controls)
.build();
content_scroller.add_css_class("ocula-content-scroll");
root.append(&content_scroller);
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, 4);
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 child = Rc::new(RefCell::new(None));
let area_preview = gtk::DrawingArea::builder()
.height_request(108)
.hexpand(true)
.build();
area_preview.add_css_class("ocula-area-preview");
area_preview.set_focusable(true);
area_preview.set_cursor_from_name(Some("pointer"));
area_preview.set_visible(false);
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 recording_settings = gtk::Box::new(gtk::Orientation::Vertical, 0);
recording_settings.add_css_class("ocula-settings-list");
recording_card.append(&recording_settings);
let audio_dropdown =
gtk::DropDown::from_strings(&["No audio", "Mic", "System", "Mic + system"]);
audio_dropdown.set_hexpand(true);
recording_settings.append(&setting_row("Audio", &audio_dropdown));
let hide_pointer_switch = gtk::Switch::builder().valign(gtk::Align::Center).build();
recording_settings.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);
recording_settings.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);
recording_settings.append(&setting_row("Encoder", &encoder_dropdown));
install_area_preview_draw(
&area_preview,
Rc::clone(&selected_area),
Rc::clone(&preview_snapshot),
);
let output_box = gtk::Box::new(gtk::Orientation::Horizontal, 6);
output_box.set_hexpand(true);
let output_entry = gtk::Entry::builder()
.hexpand(true)
.placeholder_text(default_output_hint(OutputFormatChoice::Mp4))
.build();
let browse_button = gtk::Button::from_icon_name("folder-open-symbolic");
browse_button.set_tooltip_text(Some("Choose output file"));
browse_button.add_css_class("ocula-file-button");
output_box.append(&output_entry);
output_box.append(&browse_button);
recording_settings.append(&setting_row("Output", &output_box));
let action_row = gtk::Box::new(gtk::Orientation::Horizontal, 10);
action_row.add_css_class("ocula-action-row");
action_row.add_css_class("ocula-action-dock");
let record_button = gtk::Button::with_label("START RECORDING");
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)"));
action_row.append(&record_button);
let status_label = gtk::Label::new(Some("STANDBY"));
status_label.add_css_class("ocula-status");
status_label.set_halign(gtk::Align::Center);
status_label.set_width_chars(12);
status_label.set_max_width_chars(18);
status_label.set_ellipsize(pango::EllipsizeMode::End);
action_row.append(&status_label);
root.append(&action_row);
let compact_bar = gtk::Box::new(gtk::Orientation::Horizontal, 4);
compact_bar.add_css_class("ocula-compact-bar");
compact_bar.set_homogeneous(true);
compact_bar.set_visible(false);
let compact_screen_button = compact_mode_button("Screen", "video-display-symbolic");
let compact_window_button = compact_mode_button("Window", "window-new-symbolic");
let compact_area_button = compact_mode_button("Area", "selection-mode-symbolic");
compact_window_button.set_group(Some(&compact_screen_button));
compact_area_button.set_group(Some(&compact_screen_button));
compact_screen_button.set_active(true);
compact_bar.append(&compact_screen_button);
compact_bar.append(&compact_window_button);
compact_bar.append(&compact_area_button);
let compact_status = gtk::Label::new(Some("●"));
compact_status.add_css_class("ocula-compact-status");
compact_status.set_tooltip_text(Some("STANDBY"));
compact_bar.append(&compact_status);
let compact_record_button = gtk::Button::from_icon_name("media-record-symbolic");
compact_record_button.add_css_class("ocula-compact-record");
compact_record_button.set_tooltip_text(Some("Start recording (Ctrl+R)"));
compact_bar.append(&compact_record_button);
root.append(&compact_bar);
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);
}
});
controls.append(&details);
let (log_tx, log_rx) = mpsc::sync_channel(LOG_CHANNEL_CAPACITY);
let (preview_tx, preview_rx) = mpsc::sync_channel(1);
let (screenshot_tx, screenshot_rx) = mpsc::sync_channel(1);
let ui = Ui {
window: window.clone(),
root,
brand,
content_scroller,
action_row,
compact_bar,
header_version: version,
capture_kind_button,
collapse_button,
compact_screen_button,
compact_window_button,
compact_area_button,
compact_record_button,
compact_status,
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,
selection_mode: Rc::new(Cell::new(None)),
selected_window_xid: Rc::new(Cell::new(None)),
preview_snapshot,
preview_snapshot_failed,
preview_animation_source: Rc::new(RefCell::new(None)),
preview_generation: Rc::new(Cell::new(0)),
preview_capture_in_flight: Rc::new(Cell::new(None)),
preview_capture_pending: Rc::new(Cell::new(None)),
preview_tx,
preview_rx: Rc::new(RefCell::new(preview_rx)),
crop_overlay,
crop_overlay_failed,
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)),
screenshot_in_flight: Rc::new(Cell::new(false)),
screenshot_tx,
screenshot_rx: Rc::new(RefCell::new(screenshot_rx)),
collapsed: Rc::new(Cell::new(false)),
expanded_size: Rc::new(Cell::new((EXPANDED_WINDOW_WIDTH, EXPANDED_WINDOW_HEIGHT))),
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_compact_controls(&ui);
connect_capture_kind_toggle(&ui);
connect_global_shortcuts(&ui);
connect_window_shutdown(&ui);
install_child_poll(&ui);
update_area_controls(&ui);
update_format_controls(&ui);
#[cfg(debug_assertions)]
if env::var_os(START_COLLAPSED_ENV).is_some() {
ui.collapse_button.set_active(true);
let window = window.clone();
glib::timeout_add_local_once(Duration::from_millis(500), move || {
eprintln!("collapsed window: {}x{}", window.width(), window.height());
});
}
window.present();
}
fn install_css() {
let provider = gtk::CssProvider::new();
provider.load_from_data(
".ocula-window { background: #05090e; color: #e8f8fc; }\n\
tooltip.background { padding: 0; border-radius: 6px; background: alpha(#07131b,0.97); border: 1px solid alpha(#62e8ff,0.24); box-shadow: 0 4px 12px rgba(0,0,0,0.26); }\n\
tooltip label { padding: 2px 5px; color: alpha(#d9f9ff,0.84); font-size: 9px; font-weight: 700; letter-spacing: 0.03em; }\n\
.ocula-header { min-height: 30px; padding: 0 9px; background: #070c12; color: #dffaff; box-shadow: none; border-bottom: 1px solid alpha(#59e6ff, 0.13); }\n\
.ocula-header-dot { color: #4ce5ff; font-size: 0.70em; }\n\
.ocula-header-name { color: #e8fbff; font-size: 0.78em; font-weight: 850; letter-spacing: 0.16em; }\n\
.ocula-header-version { color: alpha(#a8dce5,0.44); font-size: 0.68em; font-weight: 700; letter-spacing: 0.04em; }\n\
.ocula-shell { background-image: linear-gradient(150deg, #09131c, #05090e 58%, #071018); }\n\
.ocula-content-scroll { background: transparent; }\n\
.ocula-content-scroll scrollbar { background: transparent; }\n\
.ocula-content-scroll scrollbar slider { min-width: 3px; min-height: 24px; border-radius: 999px; background: alpha(#68e8fb,0.22); }\n\
.ocula-brand { min-height: 54px; padding: 9px 11px; border-radius: 12px; background-image: linear-gradient(120deg, alpha(#102936, 0.94), alpha(#09141d, 0.92)); border: 1px solid alpha(#5be7ff, 0.21); border-top-color: alpha(#b9f7ff, 0.30); box-shadow: 0 8px 18px rgba(0,0,0,0.20), inset 0 1px alpha(#ffffff,0.035); }\n\
.ocula-brand-mark { min-width: 36px; min-height: 36px; border-radius: 10px; color: #061116; background-image: linear-gradient(145deg, #a6f5ff, #34d9f5 56%, #13a9ca); border: 1px solid #8ff1ff; box-shadow: 0 0 12px alpha(#43ddfa, 0.18); }\n\
.ocula-brand-eyebrow { color: #55e4ff; font-size: 0.68em; font-weight: 800; letter-spacing: 0.15em; }\n\
.ocula-brand-title { color: #f4fdff; font-size: 1.10em; font-weight: 850; letter-spacing: -0.02em; }\n\
.ocula-brand-subtitle { color: alpha(#c7e6ed, 0.54); font-size: 0.68em; }\n\
.ocula-shortcut { padding: 4px 6px; border-radius: 6px; color: alpha(#c9f7ff, 0.68); background: alpha(#02070b, 0.50); border: 1px solid alpha(#69eaff, 0.17); font-size: 0.63em; font-weight: 800; letter-spacing: 0.08em; }\n\
.ocula-card { padding: 10px; border-radius: 12px; background-image: linear-gradient(145deg, alpha(#101c26, 0.92), alpha(#0b141d, 0.90)); border: 1px solid alpha(#9ac5d0, 0.12); border-top-color: alpha(#caeef5, 0.16); box-shadow: 0 7px 16px rgba(0,0,0,0.17), inset 0 1px alpha(#ffffff,0.022); }\n\
.ocula-capture-card { background-image: linear-gradient(135deg, alpha(#10242f, 0.92), alpha(#0b151e, 0.90)); }\n\
.ocula-controls.ocula-area-ready .ocula-capture-card { border-color: alpha(#5de9ff, 0.42); box-shadow: inset 2px 0 #3edff8, 0 0 15px alpha(#25d7f4,0.06), 0 7px 16px rgba(0,0,0,0.17); }\n\
.ocula-controls.ocula-recording .ocula-card { border-color: alpha(#ff6470, 0.32); }\n\
.ocula-section-heading { margin-bottom: 1px; }\n\
.ocula-section-marker { min-width: 3px; min-height: 31px; border-radius: 999px; background-image: linear-gradient(#8af3ff, #1fc8e7); box-shadow: 0 0 10px alpha(#49dff7,0.24); }\n\
.ocula-section-title { color: #ecfbff; font-size: 0.98em; font-weight: 820; letter-spacing: 0.01em; }\n\
.ocula-section-subtitle { color: alpha(#bad7de, 0.54); font-size: 0.72em; }\n\
.ocula-mode-strip { padding: 4px; border-radius: 13px; background: alpha(#03080c, 0.72); border: 1px solid alpha(#6ddff2, 0.12); box-shadow: inset 0 1px rgba(0,0,0,0.55); }\n\
.ocula-mode-button { min-height: 44px; padding: 5px 8px; border-radius: 9px; background: transparent; border: 1px solid transparent; box-shadow: none; color: alpha(#c7e0e6, 0.62); font-size: 0.76em; font-weight: 760; letter-spacing: 0.025em; }\n\
.ocula-mode-button:hover { color: #edfcff; background: alpha(#6eeaff, 0.075); border-color: alpha(#7beafa,0.09); }\n\
.ocula-mode-button:checked { color: #041015; background-image: linear-gradient(145deg, #98f3ff, #3bdcf5 62%, #20b8d5); border-color: #8bf1ff; box-shadow: 0 5px 15px alpha(#21cde9, 0.17), inset 0 1px alpha(#ffffff,0.42); }\n\
.ocula-settings-list { padding: 0 7px; border-radius: 9px; background: alpha(#030a0f,0.44); border: 1px solid alpha(#78ddec,0.08); }\n\
.ocula-setting-row { min-height: 31px; padding: 1px; border-bottom: 1px solid alpha(#a5d5df, 0.05); }\n\
.ocula-settings-list .ocula-setting-row:last-child { border-bottom-color: transparent; }\n\
.ocula-setting-label { color: alpha(#d8edf2, 0.72); font-weight: 700; font-size: 0.80em; letter-spacing: 0.015em; }\n\
.ocula-area-row { padding: 5px 7px; border-radius: 9px; background: alpha(#030a0f, 0.58); border: 1px solid alpha(#70e6fa, 0.12); }\n\
.ocula-area-selected { color: #70ecff; font-weight: 800; }\n\
.ocula-card button { min-height: 28px; padding: 4px 9px; border-radius: 8px; background-image: linear-gradient(alpha(#172832,0.92), alpha(#12202a,0.92)); border: 1px solid alpha(#9ad9e5, 0.13); box-shadow: inset 0 1px alpha(#ffffff,0.022); color: #e5f7fa; font-weight: 700; }\n\
.ocula-card button:hover { background-image: linear-gradient(#1b303b, #152731); border-color: alpha(#72e9fc, 0.34); color: #ffffff; }\n\
.ocula-area-select-button { color: #041116; background-image: linear-gradient(145deg, #91f3ff, #37d8f1); border-color: #72eafd; font-weight: 820; }\n\
.ocula-area-select-button:hover { background-image: linear-gradient(145deg, #b4f8ff, #54e3f8); border-color: #a8f6ff; }\n\
.ocula-area-select-button.ocula-crop-ready { color: #061116; background-image: linear-gradient(145deg, #a0f5ff, #48e0f6); border-color: #8af1ff; }\n\
.ocula-file-button { min-width: 32px; padding: 4px; }\n\
.ocula-card entry, .ocula-card dropdown button { min-height: 29px; border-radius: 8px; background: alpha(#03090e, 0.62); border: 1px solid alpha(#94d9e6, 0.12); box-shadow: inset 0 1px 2px rgba(0,0,0,0.20); color: #e6f8fb; }\n\
.ocula-card entry:focus, .ocula-card dropdown button:focus { border-color: alpha(#64e8ff,0.55); box-shadow: 0 0 0 2px alpha(#3bdff7,0.10); }\n\
.ocula-card switch { background: #081118; border: 1px solid alpha(#98d4df,0.14); }\n\
.ocula-card switch:checked { background: #29cde8; border-color: #68e9fb; }\n\
.ocula-card switch slider { background: #dffaff; }\n\
.ocula-area-preview { border-radius: 10px; background: alpha(#03080c,0.90); border: 1px solid alpha(#61e5fb, 0.21); box-shadow: inset 0 0 0 1px alpha(#000000, 0.30), 0 5px 14px rgba(0,0,0,0.14); }\n\
.ocula-area-preview:focus { border-color: #65e9ff; box-shadow: 0 0 0 2px alpha(#4ce3fb, 0.14), 0 5px 14px rgba(0,0,0,0.14); }\n\
.ocula-action-dock { min-height: 44px; padding: 6px; border-radius: 12px; background-image: linear-gradient(120deg, alpha(#0d202a,0.94), alpha(#09131b,0.92)); border: 1px solid alpha(#65e7fb, 0.20); border-top-color: alpha(#a6f3ff,0.28); box-shadow: 0 8px 18px rgba(0,0,0,0.22), inset 0 1px alpha(#ffffff,0.03); }\n\
.ocula-record-button { min-height: 36px; padding: 6px 14px; border-radius: 9px; color: #031015; background-image: linear-gradient(145deg, #a7f6ff, #42def5 58%, #20bddb); border: 1px solid #8cf2ff; box-shadow: 0 4px 12px alpha(#24cde8,0.16), inset 0 1px alpha(#ffffff,0.36); font-weight: 880; letter-spacing: 0.075em; }\n\
.ocula-record-button:hover { color: #020b0e; background-image: linear-gradient(145deg, #ccfbff, #64e8fa 58%, #31cbe5); border-color: #c1faff; }\n\
.ocula-record-button.ocula-recording { color: #ffffff; background-image: linear-gradient(145deg, #ff7580, #e94453 65%, #c82739); border-color: #ff8791; box-shadow: 0 6px 18px alpha(#e73e4c,0.22); }\n\
.ocula-status { padding: 6px 8px; border-radius: 8px; color: #7beaff; background: alpha(#02080c, 0.62); border: 1px solid alpha(#5ee5fb, 0.18); font-size: 0.70em; font-weight: 820; letter-spacing: 0.075em; }\n\
.ocula-status.ocula-recording { color: #ffdbe0; background: alpha(#3a1118,0.82); border-color: alpha(#ff6672, 0.48); }\n\
.ocula-collapse-button { min-width: 28px; min-height: 28px; padding: 2px; border-radius: 8px; color: alpha(#bceff7,0.66); }\n\
.ocula-collapse-button:hover, .ocula-collapse-button:checked { color: #74edff; background: alpha(#5be7ff,0.09); }\n\
.ocula-capture-kind-button { min-width: 28px; min-height: 28px; padding: 2px; border-radius: 8px; color: alpha(#bceff7,0.66); }\n\
.ocula-capture-kind-button:hover, .ocula-capture-kind-button:checked { color: #74edff; background: alpha(#5be7ff,0.09); }\n\
.ocula-compact-bar { min-height: 40px; padding: 4px; border-radius: 11px; background-image: linear-gradient(120deg, alpha(#0d202a,0.96), alpha(#071119,0.96)); border: 1px solid alpha(#65e7fb,0.24); box-shadow: 0 7px 18px rgba(0,0,0,0.24), inset 0 1px alpha(#ffffff,0.035); }\n\
.ocula-compact-mode { min-width: 34px; min-height: 34px; padding: 3px; border-radius: 8px; color: alpha(#c7e7ed,0.58); background: alpha(#02070b,0.44); border: 1px solid alpha(#65e7fb,0.07); box-shadow: none; }\n\
.ocula-compact-mode:hover { color: #f0fdff; background: alpha(#6eeaff,0.07); }\n\
.ocula-compact-mode:checked { color: #041015; background-image: linear-gradient(145deg,#98f3ff,#3bdcf5); border-color: #86efff; box-shadow: 0 3px 9px alpha(#21cde9,0.16); }\n\
.ocula-compact-status { min-width: 34px; min-height: 34px; border-radius: 8px; color: #59e7ff; background: alpha(#02070b,0.44); border: 1px solid alpha(#65e7fb,0.07); font-size: 0.78em; }\n\
.ocula-compact-status.ocula-ready { color: #59e7ff; text-shadow: 0 0 8px alpha(#45dff8,0.40); }\n\
.ocula-compact-status.ocula-recording { color: #ff5969; text-shadow: 0 0 8px alpha(#ff4458,0.48); }\n\
.ocula-compact-status.ocula-warning { color: #ffc968; }\n\
.ocula-compact-record { min-width: 34px; min-height: 34px; padding: 3px; border-radius: 8px; color: #031015; background-image: linear-gradient(145deg,#a7f6ff,#42def5 58%,#20bddb); border: 1px solid #8cf2ff; box-shadow: 0 4px 12px alpha(#24cde8,0.16); }\n\
.ocula-compact-record:hover { background-image: linear-gradient(145deg,#ccfbff,#64e8fa 58%,#31cbe5); }\n\
.ocula-compact-record.ocula-recording { color: #ffffff; background-image: linear-gradient(145deg,#ff7580,#e94453 65%,#c82739); border-color: #ff8791; }\n\
.ocula-shell.ocula-collapsed { padding: 0; }\n\
.ocula-card button:focus-visible, .ocula-record-button:focus-visible, .ocula-compact-mode:focus-visible, .ocula-compact-record:focus-visible, .ocula-collapse-button:focus-visible, .ocula-capture-kind-button:focus-visible { outline: 2px solid #78edff; outline-offset: 2px; }\n\
.ocula-card:disabled { opacity: 0.48; }\n\
.ocula-details { margin: 1px 4px; color: alpha(#c4e4ea, 0.55); font-size: 0.78em; }\n\
.ocula-log { padding: 10px; font-size: 11px; color: alpha(#d3f1f5, 0.78); background: #03080c; border-radius: 10px; border: 1px solid alpha(#71dff2, 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 brand_panel() -> gtk::Box {
let brand = gtk::Box::new(gtk::Orientation::Horizontal, 10);
brand.add_css_class("ocula-brand");
brand.set_valign(gtk::Align::Center);
let mark = gtk::Box::new(gtk::Orientation::Vertical, 0);
mark.add_css_class("ocula-brand-mark");
mark.set_halign(gtk::Align::Center);
mark.set_valign(gtk::Align::Center);
let icon = gtk::Image::from_icon_name("media-record-symbolic");
icon.set_pixel_size(17);
mark.append(&icon);
brand.append(&mark);
let copy = gtk::Box::new(gtk::Orientation::Vertical, 0);
copy.set_hexpand(true);
let eyebrow = gtk::Label::new(Some("OCULA / CAPTURE CONSOLE"));
eyebrow.set_xalign(0.0);
eyebrow.add_css_class("ocula-brand-eyebrow");
let title = gtk::Label::new(Some("Frame it. Record it."));
title.set_xalign(0.0);
title.add_css_class("ocula-brand-title");
let subtitle = gtk::Label::new(Some("A clean path from screen to file."));
subtitle.set_xalign(0.0);
subtitle.add_css_class("ocula-brand-subtitle");
copy.append(&eyebrow);
copy.append(&title);
copy.append(&subtitle);
brand.append(©);
let shortcut = gtk::Label::new(Some("CTRL R"));
shortcut.set_tooltip_text(Some("Start or stop recording"));
shortcut.add_css_class("ocula-shortcut");
brand.append(&shortcut);
brand
}
fn section_heading(title: &str, subtitle: &str) -> gtk::Box {
let heading = gtk::Box::new(gtk::Orientation::Horizontal, 9);
heading.add_css_class("ocula-section-heading");
let marker = gtk::Box::new(gtk::Orientation::Vertical, 0);
marker.add_css_class("ocula-section-marker");
heading.append(&marker);
let copy = gtk::Box::new(gtk::Orientation::Vertical, 1);
copy.set_hexpand(true);
let title = gtk::Label::new(Some(title));
title.set_xalign(0.0);
title.add_css_class("ocula-section-title");
copy.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");
copy.append(&subtitle);
heading.append(©);
heading
}
fn mode_button(text: &str, icon_name: &str) -> gtk::ToggleButton {
let content = gtk::Box::new(gtk::Orientation::Vertical, 3);
content.set_halign(gtk::Align::Center);
let icon = gtk::Image::from_icon_name(icon_name);
icon.set_pixel_size(18);
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.add_css_class("ocula-mode-button");
button
}
fn compact_mode_button(label: &str, icon_name: &str) -> gtk::ToggleButton {
let button = gtk::ToggleButton::new();
button.set_icon_name(icon_name);
button.set_tooltip_text(Some(label));
button.add_css_class("ocula-compact-mode");
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.set_width_chars(10);
label.set_max_width_chars(10);
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(),
started_at.elapsed().as_secs_f64(),
);
});
}
fn start_area_preview_animation(ui: &Ui) {
if ui.preview_animation_source.borrow().is_some()
|| !mode_uses_frame(selected_mode(ui))
|| ui.child.borrow().is_some()
|| ui.preview_snapshot.borrow().is_some()
{
return;
}
let settings = gtk::Settings::default();
if settings
.as_ref()
.is_some_and(|settings| !settings.is_gtk_enable_animations())
{
return;
}
let started_at = Instant::now();
let source = Rc::clone(&ui.preview_animation_source);
let ui_for_tick = ui.clone();
let source_id = glib::timeout_add_local(PREVIEW_ANIMATION_FRAME, move || {
let should_continue = !ui_for_tick.window_destroyed.get()
&& mode_uses_frame(selected_mode(&ui_for_tick))
&& ui_for_tick.area_preview.is_visible()
&& ui_for_tick.child.borrow().is_none()
&& ui_for_tick.preview_snapshot.borrow().is_none()
&& started_at.elapsed() < PREVIEW_ANIMATION_DURATION;
if !should_continue {
source.borrow_mut().take();
return glib::ControlFlow::Break;
}
ui_for_tick.area_preview.queue_draw();
glib::ControlFlow::Continue
});
ui.preview_animation_source.replace(Some(source_id));
}
fn request_area_snapshot(ui: &Ui, selected: SelectedArea) {
let current_generation = ui.preview_generation.get();
let already_queued = ui.preview_capture_pending.get().is_some_and(|request| {
request.generation == current_generation && request.selected == selected
}) || ui
.preview_capture_in_flight
.get()
.is_some_and(|(request, _)| {
request.generation == current_generation && request.selected == selected
});
if already_queued {
return;
}
let generation = current_generation.wrapping_add(1);
ui.preview_generation.set(generation);
ui.preview_capture_pending.set(Some(PreviewRequest {
generation,
selected,
}));
start_pending_area_snapshot(ui);
}
fn start_pending_area_snapshot(ui: &Ui) {
if ui.preview_capture_in_flight.get().is_some() {
return;
}
let Some(request) = ui.preview_capture_pending.take() else {
return;
};
if request.generation != ui.preview_generation.get()
|| !preview_capture_allowed(ui, request.selected)
{
return;
}
let tx = ui.preview_tx.clone();
ui.preview_capture_in_flight
.set(Some((request, Instant::now())));
if let Err(err) = thread::Builder::new()
.name("ocula-preview".to_string())
.spawn(move || {
let _ = tx.send((request, capture_area_snapshot(request.selected)));
})
{
ui.preview_capture_in_flight.set(None);
ui.preview_snapshot_failed.set(Some(request.selected));
append_log(
&ui.log_buffer,
&format!("Could not start area preview capture: {err}"),
);
}
}
fn preview_capture_allowed(ui: &Ui, selected: SelectedArea) -> bool {
let mode = selected_mode(ui);
!ui.window_destroyed.get()
&& mode_uses_frame(mode)
&& ui.child.borrow().is_none()
&& !ui.selector_open.get()
&& ui.x11_selector_available
&& ui.selection_mode.get() == Some(mode)
&& *ui.selected_area.borrow() == Some(selected)
&& ui.preview_snapshot_failed.get() != Some(selected)
}
fn invalidate_preview_requests(ui: &Ui) {
if ui.preview_capture_pending.take().is_some() || ui.preview_capture_in_flight.get().is_some() {
ui.preview_generation
.set(ui.preview_generation.get().wrapping_add(1));
}
}
fn apply_preview_capture_results(ui: &Ui) {
if let Some((request, started_at)) = ui.preview_capture_in_flight.get()
&& started_at.elapsed() >= PREVIEW_CAPTURE_TIMEOUT
{
ui.preview_capture_in_flight.set(None);
if request.generation == ui.preview_generation.get() {
ui.preview_generation
.set(ui.preview_generation.get().wrapping_add(1));
ui.preview_capture_pending.set(None);
ui.preview_snapshot_failed.set(Some(request.selected));
append_log(&ui.log_buffer, "Area preview capture timed out");
} else {
start_pending_area_snapshot(ui);
}
}
loop {
let result = ui.preview_rx.borrow().try_recv();
let Ok((request, result)) = result else {
break;
};
if ui
.preview_capture_in_flight
.get()
.is_none_or(|(active, _)| active != request)
{
continue;
}
ui.preview_capture_in_flight.set(None);
if request.generation != ui.preview_generation.get()
|| !preview_capture_allowed(ui, request.selected)
{
start_pending_area_snapshot(ui);
continue;
}
match result.and_then(PreviewImage::into_snapshot) {
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(request.selected));
ui.preview_snapshot.replace(None);
ui.area_preview.queue_draw();
append_log(
&ui.log_buffer,
&format!("Area preview unavailable: {err:#}"),
);
}
}
start_pending_area_snapshot(ui);
}
}
fn draw_area_preview(
cr: >k::cairo::Context,
width: i32,
height: i32,
selected: Option<SelectedArea>,
snapshot: Option<&PreviewSnapshot>,
phase: f64,
) {
let width = f64::from(width.max(1));
let height = f64::from(height.max(1));
let style = PREVIEW_STYLE;
let bg = gtk::cairo::LinearGradient::new(0.0, 0.0, width, height);
bg.add_color_stop_rgba(0.0, 0.012, 0.032, 0.046, 1.0);
bg.add_color_stop_rgba(0.55, 0.018, 0.055, 0.072, 1.0);
bg.add_color_stop_rgba(1.0, 0.025, 0.075, 0.094, 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 phase = if has_snapshot { 0.0 } else { phase };
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;
};
if !has_snapshot {
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 {
draw_preview_unavailable_fill(cr, rect, phase, style);
}
draw_preview_border(cr, rect, phase, style);
if !has_snapshot {
draw_preview_locator(cr, selected, width, height, style);
}
draw_preview_chip(
cr,
14.0,
14.0,
&format!(
"{} x {} / X{} Y{}",
selected.area.width, selected.area.height, selected.area.x, selected.area.y
),
style.primary,
);
if !has_snapshot {
draw_preview_chip(
cr,
14.0,
height - 34.0,
"PREVIEW UNAVAILABLE",
style.tertiary,
);
}
}
fn draw_preview_grid(cr: >k::cairo::Context, width: f64, height: f64) {
cr.set_line_width(1.0);
cr.set_source_rgba(0.30, 0.82, 0.92, 0.038);
let mut x = 28.0;
while x < width {
cr.move_to(x, 0.0);
cr.line_to(x, height);
x += 28.0;
}
let mut y = 28.0;
while y < height {
cr.move_to(0.0, y);
cr.line_to(width, y);
y += 28.0;
}
let _ = cr.stroke();
cr.set_source_rgba(0.34, 0.88, 0.98, 0.055);
cr.move_to(width / 2.0, 0.0);
cr.line_to(width / 2.0, height);
cr.move_to(0.0, height / 2.0);
cr.line_to(width, height / 2.0);
let _ = cr.stroke();
}
fn draw_preview_empty_state(
cr: >k::cairo::Context,
width: f64,
height: f64,
phase: f64,
style: PreviewStyle,
) {
let rect = PreviewRect {
x: width * 0.14,
y: height * 0.20,
width: width * 0.72,
height: height * 0.60,
};
draw_preview_aura(cr, rect, phase, style);
draw_preview_card(cr, rect, phase, style);
draw_preview_border(cr, rect, phase, style);
draw_preview_reticle(cr, rect, phase, style);
draw_preview_chip(
cr,
rect.x + (rect.width - preview_chip_width("CLICK TO FRAME AREA")) / 2.0,
rect.y + rect.height / 2.0 - 11.5,
"CLICK TO FRAME AREA",
style.primary,
);
draw_preview_chip(cr, 14.0, 14.0, "FRAME PREVIEW", style.tertiary);
}
fn draw_preview_reticle(
cr: >k::cairo::Context,
rect: PreviewRect,
phase: f64,
style: PreviewStyle,
) {
let cx = rect.x + rect.width / 2.0;
let cy = rect.y + rect.height / 2.0;
let radius = 12.0 + phase.sin().abs() * 2.0;
cr.set_line_width(1.0);
set_preview_color(cr, style.primary, 0.32);
cr.arc(cx, cy, radius, 0.0, PI * 2.0);
let _ = cr.stroke();
for (x1, y1, x2, y2) in [
(cx - 28.0, cy, cx - 16.0, cy),
(cx + 16.0, cy, cx + 28.0, cy),
(cx, cy - 28.0, cx, cy - 16.0),
(cx, cy + 16.0, cx, cy + 28.0),
] {
cr.move_to(x1, y1);
cr.line_to(x2, y2);
}
let _ = cr.stroke();
}
fn draw_preview_unavailable_fill(
cr: >k::cairo::Context,
rect: PreviewRect,
phase: f64,
style: PreviewStyle,
) {
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());
gradient.add_color_stop_rgba(0.0, style.primary.0, style.primary.1, style.primary.2, 0.20);
gradient.add_color_stop_rgba(
0.54,
style.tertiary.0,
style.tertiary.1,
style.tertiary.2,
0.10,
);
gradient.add_color_stop_rgba(
1.0,
style.secondary.0,
style.secondary.1,
style.secondary.2,
0.18,
);
cr.rectangle(rect.x, rect.y, rect.width, rect.height);
let _ = cr.set_source(&gradient);
let _ = cr.fill();
cr.set_source_rgba(0.55, 0.94, 1.0, 0.12);
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_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: PreviewStyle) {
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.68 + 0.025 * 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.16);
aura.add_color_stop_rgba(
0.55,
style.secondary.0,
style.secondary.1,
style.secondary.2,
0.055,
);
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: PreviewStyle) {
let _ = cr.save();
rounded_rectangle(
cr,
PreviewRect {
x: rect.x - 5.0,
y: rect.y + 7.0,
width: rect.width + 10.0,
height: rect.height + 10.0,
},
16.0,
);
cr.set_source_rgba(0.0, 0.0, 0.0, 0.34);
let _ = cr.fill();
let glass = gtk::cairo::LinearGradient::new(rect.x, rect.y, rect.right(), rect.bottom());
glass.add_color_stop_rgba(0.0, 0.70, 0.95, 1.0, 0.11);
glass.add_color_stop_rgba(
0.24,
style.tertiary.0,
style.tertiary.1,
style.tertiary.2,
0.055,
);
glass.add_color_stop_rgba(
0.72,
style.secondary.0 * 0.22,
style.secondary.1 * 0.22,
style.secondary.2 * 0.34,
0.12,
);
glass.add_color_stop_rgba(1.0, 0.01, 0.035, 0.055, 0.58);
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, 0.65, 0.95, 1.0, 0.01);
sheen.add_color_stop_rgba(0.50, 0.65, 0.95, 1.0, 0.07 + 0.02 * phase.sin().abs());
sheen.add_color_stop_rgba(1.0, 0.65, 0.95, 1.0, 0.01);
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: PreviewStyle,
) {
rounded_rectangle(cr, rect, 14.0);
cr.set_line_width(1.5);
set_preview_color(cr, style.tertiary, 0.54);
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() * 0.8);
set_preview_color(cr, style.primary, 0.28);
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: PreviewStyle,
) {
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(
"Monospace",
gtk::cairo::FontSlant::Normal,
gtk::cairo::FontWeight::Bold,
);
cr.set_font_size(10.5);
cr.set_source_rgba(0.0, 0.0, 0.0, 0.52);
cr.move_to(x + 1.0, y + 1.0);
let _ = cr.show_text(text);
cr.set_source_rgba(0.88, 0.98, 1.0, 0.90);
cr.move_to(x, y);
let _ = cr.show_text(text);
}
fn draw_preview_corner_brackets(
cr: >k::cairo::Context,
rect: PreviewRect,
phase: f64,
style: PreviewStyle,
) {
let _ = cr.save();
let length = rect.width.min(rect.height).clamp(16.0, 25.0);
let inset = 6.0;
let pulse = 0.60 + 0.12 * phase.sin().abs();
cr.set_line_width(2.2);
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();
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 = 22.0;
rounded_rectangle(
cr,
PreviewRect {
x,
y,
width,
height,
},
7.0,
);
cr.set_source_rgba(0.01, 0.035, 0.05, 0.82);
let _ = cr.fill_preserve();
cr.set_line_width(1.0);
set_preview_color(cr, accent, 0.42);
let _ = cr.stroke();
rounded_rectangle(
cr,
PreviewRect {
x: x + 5.0,
y: y + 5.0,
width: 3.0,
height: height - 10.0,
},
2.0,
);
set_preview_color(cr, accent, 0.88);
let _ = cr.fill();
draw_preview_text(cr, x + 13.0, y + 15.5, text);
}
fn preview_chip_width(text: &str) -> f64 {
(text.len() as f64 * 6.6 + 21.0).max(70.0)
}
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,
})
}
impl PreviewImage {
fn into_snapshot(self) -> Result<PreviewSnapshot> {
let surface = gtk::cairo::ImageSurface::create_for_data(
self.data,
gtk::cairo::Format::Rgb24,
self.width,
self.height,
self.stride,
)
.context("failed to create preview surface")?;
surface.mark_dirty();
Ok(PreviewSnapshot {
surface,
width: self.width,
height: self.height,
})
}
}
fn capture_area_snapshot(selected: SelectedArea) -> Result<PreviewImage> {
capture_area_image(selected, PREVIEW_CAPTURE_MAX_PIXELS, "preview")
}
fn capture_area_image(
selected: SelectedArea,
max_pixels: i64,
purpose: &str,
) -> Result<PreviewImage> {
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;
capture_x11_image(
&conn,
screen,
screen.root,
x,
y,
width,
height,
max_pixels,
purpose,
)
}
fn capture_window_image(window: Window) -> Result<PreviewImage> {
let (conn, screen_num) = connect(None).context("failed to connect to X11 for screenshot")?;
let screen = &conn.setup().roots[screen_num];
let geometry = conn
.get_geometry(window)?
.reply()
.context("selected window closed before the screenshot")?;
capture_x11_image(
&conn,
screen,
window,
0,
0,
i32::from(geometry.width),
i32::from(geometry.height),
SCREENSHOT_CAPTURE_MAX_PIXELS,
"screenshot",
)
}
#[allow(clippy::too_many_arguments)]
fn capture_x11_image(
conn: &RustConnection,
screen: &Screen,
drawable: Window,
x: i32,
y: i32,
width: i32,
height: i32,
max_pixels: i64,
purpose: &str,
) -> Result<PreviewImage> {
ensure!(width > 0 && height > 0, "selected {purpose} area is empty");
ensure!(
i64::from(width) * i64::from(height) <= max_pixels,
"selected area is too large for a {purpose}"
);
let reply = conn
.get_image(
ImageFormat::Z_PIXMAP,
drawable,
i16::try_from(x).with_context(|| format!("{purpose} x is outside X11 range"))?,
i16::try_from(y).with_context(|| format!("{purpose} y is outside X11 range"))?,
u16::try_from(width)
.with_context(|| format!("{purpose} width is outside X11 range"))?,
u16::try_from(height)
.with_context(|| format!("{purpose} height is outside X11 range"))?,
u32::MAX,
)
.with_context(|| format!("failed to request X11 {purpose} image"))?
.reply()
.with_context(|| format!("failed to read X11 {purpose} image"))?;
let setup = conn.setup();
let format = setup
.pixmap_formats
.iter()
.find(|format| format.depth == reply.depth)
.with_context(|| format!("X11 {purpose} format is unsupported"))?;
let visual = find_visual(screen, reply.visual)
.or_else(|| find_visual(screen, screen.root_visual))
.with_context(|| format!("X11 {purpose} visual is unsupported"))?;
let source_stride = x11_image_stride(width, format.bits_per_pixel, format.scanline_pad)
.with_context(|| format!("X11 {purpose} 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 {purpose} pixel size is unsupported"
);
ensure!(
reply.data.len() >= source_stride.saturating_mul(usize::try_from(height)?),
"X11 {purpose} image data is truncated"
);
let stride = gtk::cairo::Format::Rgb24
.stride_for_width(u32::try_from(width)?)
.with_context(|| format!("{purpose} stride is invalid"))?;
let target_stride =
usize::try_from(stride).with_context(|| format!("{purpose} stride is invalid"))?;
let data_len = target_stride
.checked_mul(usize::try_from(height)?)
.with_context(|| format!("{purpose} image is too large"))?;
let mut data = vec![0; data_len];
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,
);
Ok(PreviewImage {
data,
width,
height,
stride,
})
}
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, CROP_OVERLAY_GLOW_OPACITY),
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, CROP_OVERLAY_EDGE_OPACITY),
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],
appearance: (u32, u32),
name: &[u8],
stack_below: Option<Window>,
) -> Result<Window> {
let (color, opacity) = appearance;
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,
&[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);
if !ui_for_window.x11_selector_available {
ui_for_window
.status_label
.set_text("Window picking unavailable");
}
}
});
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 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 {
mode_uses_frame(selected_mode(ui)) && ui.child.borrow().is_none() && ui.x11_selector_available
}
fn open_area_selector(ui: &Ui) {
let mode = selected_mode(ui);
if !mode_uses_frame(mode) {
return;
}
if !ui.x11_selector_available {
ui.status_label.set_text(if mode == Mode::Window {
"Window picking unavailable"
} else {
"Framing unavailable"
});
append_log(
&ui.log_buffer,
"Click selection requires an available X11 or XWayland display",
);
return;
}
if ui.selector_open.replace(true) {
return;
}
invalidate_preview_requests(ui);
ui.window.set_visible(false);
let ui = ui.clone();
glib::idle_add_local_once(move || run_area_selector(&ui, mode));
}
fn run_area_selector(ui: &Ui, mode: Mode) {
let previous = if ui.selection_mode.get() == Some(mode) {
*ui.selected_area.borrow()
} else {
None
};
ui.crop_overlay.borrow_mut().take();
ui.crop_overlay_failed.set(None);
let selection = match mode {
Mode::Window => {
ui.status_label.set_text("Click the window to record");
append_log(&ui.log_buffer, "Waiting for an X11/XWayland window click");
selector::select_window_once().map(|window| (window.selection, Some(window.xid)))
}
Mode::Area => {
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)
),
);
selector::adjust_area_once_with_effect(selected.area, SELECTOR_VISUAL_EFFECT)
.map(|selection| (selection, None))
} else {
ui.status_label.set_text("Drag to frame the recording");
append_log(&ui.log_buffer, "Opening recording frame selector");
selector::select_area_once_with_effect(SELECTOR_VISUAL_EFFECT)
.map(|selection| (selection, None))
}
}
Mode::Screen => unreachable!("screen mode does not use a frame selector"),
};
ui.selector_open.set(false);
match selection {
Ok((selection, window_xid)) => {
let selected = SelectedArea {
area: selection.area,
screen_width: selection.screen_width,
screen_height: selection.screen_height,
};
if previous != Some(selected) {
ui.preview_snapshot.replace(None);
}
ui.preview_snapshot_failed.set(None);
ui.selection_mode.set(Some(mode));
ui.selected_window_xid.set(window_xid);
ui.selected_area.replace(Some(selected));
request_area_snapshot(ui, selected);
ui.area_preview.queue_draw();
ui.status_label.set_text(if mode == Mode::Window {
"Window ready"
} else {
"Frame ready"
});
append_log(
&ui.log_buffer,
&format!(
"Selected {} {}",
if mode == Mode::Window {
"window"
} else {
"frame"
},
area_label(selected)
),
);
}
Err(err) => {
if selection_was_cancelled(&err) {
ui.status_label.set_text("Selection canceled");
append_log(&ui.log_buffer, "Capture selection cancelled");
} else {
append_log(
&ui.log_buffer,
&format!("Capture selector unavailable: {err:#}"),
);
ui.status_label.set_text(if mode == Mode::Window {
"Window unavailable"
} else {
"Framing unavailable"
});
}
}
}
update_area_controls(ui);
ui.window.present();
}
fn update_area_controls(ui: &Ui) {
let mode = selected_mode(ui);
let uses_frame = mode_uses_frame(mode);
let is_recording = ui.child.borrow().is_some();
let is_busy = is_recording || ui.screenshot_in_flight.get();
if uses_frame
&& ui
.selection_mode
.get()
.is_some_and(|selection_mode| selection_mode != mode)
{
invalidate_preview_requests(ui);
ui.selection_mode.set(None);
ui.selected_window_xid.set(None);
ui.selected_area.replace(None);
ui.preview_snapshot.replace(None);
ui.preview_snapshot_failed.set(None);
}
let selected = selected_frame(ui, mode);
let has_frame = selected.is_some();
if !uses_frame || is_busy {
invalidate_preview_requests(ui);
}
ui.area_row.set_visible(uses_frame);
ui.area_preview.set_visible(uses_frame);
if let Some(selected) = selected {
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(if mode == Mode::Window {
"Change Window"
} else {
"Refine Frame"
});
ui.select_area_button.add_css_class("ocula-crop-ready");
ui.area_preview.queue_draw();
} else {
ui.area_label.set_text(if mode == Mode::Window {
"No window selected"
} else {
"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(if mode == Mode::Window {
"Choose Window"
} else {
"Frame Area"
});
ui.select_area_button.remove_css_class("ocula-crop-ready");
}
let preview_tooltip = if is_busy {
"Capture selection is locked while working"
} else if mode == Mode::Window && has_frame {
"Click or press Enter to choose another window"
} else if mode == Mode::Window {
"Click or press Enter, then click the window to record"
} else if has_frame {
"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_busy));
if uses_frame {
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_busy);
ui.capture_kind_button.set_sensitive(!is_busy);
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 uses_frame && has_frame {
ui.controls_card.add_css_class("ocula-area-ready");
}
sync_crop_overlay(ui);
if !is_busy {
ui.record_button.set_sensitive(!uses_frame || has_frame);
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(is_recording);
ui.select_area_button.set_sensitive(false);
ui.area_preview.set_sensitive(false);
}
if !is_busy
&& let Some(selected) = selected
&& ui.preview_snapshot.borrow().is_none()
&& ui.preview_snapshot_failed.get() != Some(selected)
{
request_area_snapshot(ui, selected);
}
start_area_preview_animation(ui);
sync_compact_controls(ui);
}
fn sync_crop_overlay(ui: &Ui) {
let mode = selected_mode(ui);
let desired = if mode == Mode::Area || (mode == Mode::Window && ui.child.borrow().is_none()) {
selected_frame(ui, mode)
} else {
None
};
if !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 selection_was_cancelled(err: &anyhow::Error) -> bool {
err.chain().any(|cause| {
matches!(
cause.to_string().as_str(),
"area selection cancelled" | "window 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 screenshot_mode(ui) {
ui.audio_dropdown.set_sensitive(false);
ui.hide_pointer_switch.set_sensitive(false);
ui.format_dropdown.set_sensitive(false);
ui.encoder_dropdown.set_sensitive(false);
ui.output_entry
.set_placeholder_text(Some(&default_screenshot_hint()));
if !ui.screenshot_in_flight.get() {
ui.status_label.set_text("SHOT READY");
}
return;
}
ui.hide_pointer_switch.set_sensitive(true);
ui.format_dropdown.set_sensitive(true);
ui.output_entry
.set_placeholder_text(Some(&default_output_hint(selected_output_format(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("STANDBY");
}
}
}
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", "png"]
.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 folder = gio::File::for_path(default_output_dir());
let screenshot = screenshot_mode(&ui);
let dialog = gtk::FileChooserNative::new(
Some(if screenshot {
"Save Screenshot"
} else {
"Save Recording"
}),
Some(&ui.window),
gtk::FileChooserAction::Save,
Some("Save"),
Some("Cancel"),
);
if let Err(err) = dialog.set_current_folder(Some(&folder)) {
append_log(
&ui.log_buffer,
&format!("Could not open the output directory: {err}"),
);
}
if screenshot {
dialog.set_current_name("Screenshot.png");
} else {
dialog.set_current_name(&format!(
"Recording.{}",
selected_output_format(&ui).extension()
));
}
let ui = ui.clone();
dialog.connect_response(move |dialog, response| {
if response == gtk::ResponseType::Accept
&& let Some(path) = dialog.file().and_then(|file| file.path())
{
ui.output_entry.set_text(&path.to_string_lossy());
}
dialog.destroy();
});
dialog.show();
});
}
fn connect_record_button(ui: &Ui) {
let ui = ui.clone();
ui.record_button.clone().connect_clicked(move |_| {
toggle_recording(&ui);
});
}
fn connect_compact_controls(ui: &Ui) {
let ui_for_collapse = ui.clone();
ui.collapse_button
.clone()
.connect_toggled(move |button| set_collapsed(&ui_for_collapse, button.is_active()));
let ui_for_screen = ui.clone();
ui.compact_screen_button
.clone()
.connect_clicked(move |_| ui_for_screen.screen_button.set_active(true));
let ui_for_window = ui.clone();
ui.compact_window_button.clone().connect_clicked(move |_| {
ui_for_window.window_button.set_active(true);
if area_preview_can_open_selector(&ui_for_window) {
open_area_selector(&ui_for_window);
}
});
let ui_for_area = ui.clone();
ui.compact_area_button.clone().connect_clicked(move |_| {
ui_for_area.area_button.set_active(true);
if area_preview_can_open_selector(&ui_for_area) {
open_area_selector(&ui_for_area);
}
});
let ui_for_record = ui.clone();
ui.compact_record_button
.clone()
.connect_clicked(move |_| toggle_recording(&ui_for_record));
}
fn connect_capture_kind_toggle(ui: &Ui) {
let ui = ui.clone();
ui.capture_kind_button
.clone()
.connect_toggled(move |_| update_capture_kind_controls(&ui));
}
fn update_capture_kind_controls(ui: &Ui) {
let screenshot = screenshot_mode(ui);
ui.capture_kind_button.set_icon_name(if screenshot {
"camera-photo-symbolic"
} else {
"camera-video-symbolic"
});
let switch_label = if screenshot {
"Switch to video recording"
} else {
"Switch to screenshot mode"
};
ui.capture_kind_button.set_tooltip_text(Some(switch_label));
if ui.child.borrow().is_none() && !ui.screenshot_in_flight.get() {
reset_record_button(ui);
}
if !screenshot {
normalize_output_extension(ui);
}
update_format_controls(ui);
update_area_controls(ui);
}
fn screenshot_mode(ui: &Ui) -> bool {
ui.capture_kind_button.is_active()
}
fn set_collapsed(ui: &Ui, collapsed: bool) {
if ui.collapsed.replace(collapsed) == collapsed {
return;
}
if collapsed {
let current = (ui.window.width(), ui.window.height());
if current.0 > 0 && current.1 > 0 {
ui.expanded_size.set(current);
}
}
ui.brand.set_visible(!collapsed);
ui.content_scroller.set_visible(!collapsed);
ui.action_row.set_visible(!collapsed);
ui.header_version.set_visible(!collapsed);
ui.compact_bar.set_visible(collapsed);
if collapsed {
ui.root.add_css_class("ocula-collapsed");
set_root_margin(&ui.root, 6);
ui.collapse_button.set_icon_name("view-fullscreen-symbolic");
ui.collapse_button
.set_tooltip_text(Some("Expand recorder controls"));
ui.window.set_resizable(true);
ui.window
.set_default_size(COLLAPSED_WINDOW_WIDTH, COLLAPSED_WINDOW_HEIGHT);
ui.window.queue_resize();
let window = ui.window.clone();
glib::idle_add_local_once(move || window.set_resizable(false));
} else {
ui.root.remove_css_class("ocula-collapsed");
set_root_margin(&ui.root, 10);
ui.collapse_button.set_icon_name("view-restore-symbolic");
ui.collapse_button
.set_tooltip_text(Some("Collapse to mini controls"));
ui.window.set_resizable(true);
let (width, height) = ui.expanded_size.get();
ui.window.set_default_size(width, height);
}
sync_compact_controls(ui);
}
fn set_root_margin(root: >k::Box, margin: i32) {
root.set_margin_top(margin);
root.set_margin_bottom(margin);
root.set_margin_start(margin);
root.set_margin_end(margin);
}
fn sync_compact_controls(ui: &Ui) {
let mode = selected_mode(ui);
ui.compact_screen_button.set_active(mode == Mode::Screen);
ui.compact_window_button.set_active(mode == Mode::Window);
ui.compact_area_button.set_active(mode == Mode::Area);
let busy = ui.child.borrow().is_some() || ui.screenshot_in_flight.get();
ui.compact_screen_button.set_sensitive(!busy);
ui.compact_window_button
.set_sensitive(!busy && ui.x11_selector_available);
ui.compact_area_button
.set_sensitive(!busy && ui.x11_selector_available);
ui.compact_record_button
.set_sensitive(ui.record_button.is_sensitive());
let record_label = ui.record_button.label().unwrap_or_default();
let record_action = compact_record_action(record_label.as_str());
ui.compact_record_button.set_icon_name(record_action.0);
ui.compact_record_button
.set_tooltip_text(Some(record_action.1));
if matches!(record_label.as_str(), "STOP & SAVE" | "FORCE STOP") {
ui.compact_record_button.add_css_class("ocula-recording");
} else {
ui.compact_record_button.remove_css_class("ocula-recording");
}
let status = ui.status_label.text();
ui.compact_status.set_tooltip_text(Some(status.as_str()));
for class in ["ocula-ready", "ocula-recording", "ocula-warning"] {
ui.compact_status.remove_css_class(class);
}
ui.compact_status
.add_css_class(compact_status_class(status.as_str()));
}
fn compact_record_action(label: &str) -> (&'static str, &'static str) {
match label {
"TAKE SCREENSHOT" | "CAPTURING..." => ("camera-photo-symbolic", "Take screenshot"),
"STOP & SAVE" => ("media-playback-stop-symbolic", "Stop and save recording"),
"FORCE STOP" => ("process-stop-symbolic", "Force stop recording"),
_ => ("media-record-symbolic", "Start recording"),
}
}
fn compact_status_class(status: &str) -> &'static str {
if status.starts_with("REC ") || status.contains("Saving") || status.contains("stopping") {
"ocula-recording"
} else if status.contains("failed")
|| status.contains("unavailable")
|| status.contains("Could not")
{
"ocula-warning"
} else {
"ocula-ready"
}
}
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 if !ui.screenshot_in_flight.get() {
if screenshot_mode(ui) {
start_screenshot(ui);
} 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(UI_POLL_INTERVAL, move || {
apply_preview_capture_results(&ui);
if ui.screenshot_in_flight.get()
&& let Ok(result) = ui.screenshot_rx.borrow().try_recv()
{
ui.screenshot_in_flight.set(false);
ui.window.present();
reset_record_button(&ui);
match result {
Ok(path) => {
ui.status_label.set_text("SHOT SAVED");
append_log(
&ui.log_buffer,
&format!("Screenshot saved to {}", path.display()),
);
}
Err(err) => {
ui.status_label.set_text("Capture failed");
append_log(&ui.log_buffer, &format!("Screenshot failed: {err}"));
}
}
update_area_controls(&ui);
}
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;
}
sync_compact_controls(&ui);
glib::ControlFlow::Continue
});
}
fn start_screenshot(ui: &Ui) {
if !ui.x11_selector_available {
ui.status_label.set_text("Screenshot unavailable");
append_log(
&ui.log_buffer,
"Screenshots require an available X11 or XWayland display",
);
return;
}
let target = match screenshot_target(ui) {
Ok(target) => target,
Err(err) => {
ui.status_label.set_text("Choose a target");
append_log(
&ui.log_buffer,
&format!("Screenshot target unavailable: {err:#}"),
);
update_area_controls(ui);
return;
}
};
let output = match screenshot_output_path(ui.output_entry.text().trim()) {
Ok(output) => output,
Err(err) => {
ui.status_label.set_text("Output unavailable");
append_log(
&ui.log_buffer,
&format!("Screenshot output is invalid: {err:#}"),
);
return;
}
};
ui.screenshot_in_flight.set(true);
ui.crop_overlay.borrow_mut().take();
ui.crop_overlay_failed.set(None);
ui.status_label.set_text("CAPTURING...");
ui.record_button.set_label("CAPTURING...");
ui.record_button.set_sensitive(false);
update_area_controls(ui);
ui.window.set_visible(false);
let tx = ui.screenshot_tx.clone();
if let Err(err) = thread::Builder::new()
.name("ocula-screenshot".to_string())
.spawn(move || {
thread::sleep(Duration::from_millis(150));
let result = capture_and_save_screenshot(target, &output)
.map(|()| output)
.map_err(|err| format!("{err:#}"));
let _ = tx.send(result);
})
{
ui.screenshot_in_flight.set(false);
ui.window.present();
ui.status_label.set_text("Capture failed");
append_log(
&ui.log_buffer,
&format!("Could not start screenshot capture: {err}"),
);
reset_record_button(ui);
update_area_controls(ui);
}
}
fn screenshot_target(ui: &Ui) -> Result<ScreenshotTarget> {
match selected_mode(ui) {
Mode::Screen => Ok(ScreenshotTarget::Screen),
Mode::Area => selected_frame(ui, Mode::Area)
.map(ScreenshotTarget::Area)
.context("frame an area first"),
Mode::Window => ui
.selected_window_xid
.get()
.map(ScreenshotTarget::Window)
.context("choose a window first"),
}
}
fn capture_and_save_screenshot(target: ScreenshotTarget, output: &Path) -> Result<()> {
let image = match target {
ScreenshotTarget::Screen => capture_area_image(
full_screen_selection()?,
SCREENSHOT_CAPTURE_MAX_PIXELS,
"screenshot",
)?,
ScreenshotTarget::Area(selected) => {
capture_area_image(selected, SCREENSHOT_CAPTURE_MAX_PIXELS, "screenshot")?
}
ScreenshotTarget::Window(window) => capture_window_image(window)?,
};
write_screenshot_png(image, output)
}
fn full_screen_selection() -> Result<SelectedArea> {
let (conn, screen_num) = connect(None).context("failed to connect to X11 for screenshot")?;
let screen = &conn.setup().roots[screen_num];
let width = i32::from(screen.width_in_pixels);
let height = i32::from(screen.height_in_pixels);
Ok(SelectedArea {
area: CaptureArea::new(0, 0, width, height)?,
screen_width: width,
screen_height: height,
})
}
fn screenshot_output_path(raw: &str) -> Result<PathBuf> {
let mut path = if raw.is_empty() {
default_output_dir().join(default_screenshot_name())
} else {
PathBuf::from(raw)
};
if path.is_dir() {
path.push(default_screenshot_name());
} else {
path.set_extension("png");
}
ensure!(
path.file_name().is_some(),
"screenshot path needs a file name"
);
if !path.exists() {
return Ok(path);
}
let stem = path
.file_stem()
.and_then(|stem| stem.to_str())
.context("screenshot file name is invalid")?;
let parent = path
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
.unwrap_or_else(|| Path::new("."));
for suffix in 1..=9999 {
let candidate = parent.join(format!("{stem}-{suffix}.png"));
if !candidate.exists() {
return Ok(candidate);
}
}
bail!("could not choose a unique screenshot file name")
}
fn default_screenshot_name() -> String {
let stamp = glib::DateTime::now_local()
.and_then(|time| time.format("%Y-%m-%d-%H-%M-%S"))
.map(|stamp| stamp.to_string())
.unwrap_or_else(|_| "unknown-time".to_string());
format!("Ocula-{stamp}.png")
}
fn default_screenshot_hint() -> String {
format!("Auto .png in {}", default_output_dir().display())
}
fn write_screenshot_png(image: PreviewImage, output: &Path) -> Result<()> {
let parent = output
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
.unwrap_or_else(|| Path::new("."));
fs::create_dir_all(parent)
.with_context(|| format!("could not create screenshot directory {}", parent.display()))?;
let temp = output.with_extension("png.part");
let result = (|| -> Result<()> {
let snapshot = image.into_snapshot()?;
let mut file = fs::File::create(&temp)
.with_context(|| format!("could not create screenshot {}", temp.display()))?;
snapshot
.surface
.write_to_png(&mut file)
.context("could not encode screenshot PNG")?;
file.sync_all()?;
fs::rename(&temp, output)
.with_context(|| format!("could not finalize screenshot {}", output.display()))?;
Ok(())
})();
if result.is_err() {
let _ = fs::remove_file(temp);
}
result
}
fn start_recording(ui: &Ui) {
let mode = selected_mode(ui);
if mode_uses_frame(mode)
&& (selected_frame(ui, mode).is_none()
|| (mode == Mode::Window && ui.selected_window_xid.get().is_none()))
{
ui.status_label.set_text(if mode == Mode::Window {
"Choose a window"
} else {
"Frame an area"
});
append_log(
&ui.log_buffer,
if mode == Mode::Window {
"Window mode requires clicking a window first"
} else {
"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 & SAVE");
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);
if screenshot_mode(ui) {
ui.record_button.set_label("TAKE SCREENSHOT");
ui.record_button
.set_tooltip_text(Some("Capture a PNG screenshot (Ctrl+R)"));
} else {
ui.record_button.set_label("START RECORDING");
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;
};
let label = format!("REC {}", format_recording_elapsed(started_at.elapsed()));
if ui.status_label.text().as_str() != label {
ui.status_label.set_text(&label);
}
}
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(recorder_mode_arg(mode).to_string());
if let Some(backend) = area_backend_arg(mode) {
args.push("--backend".to_string());
args.push(backend.to_string());
}
if mode == Mode::Window
&& let Some(selected) = selected_frame(ui, mode)
&& let Some(window_xid) = ui.selected_window_xid.get()
{
args.push("--x11-window".to_string());
args.push(window_xid.to_string());
args.push("--x11-window-size".to_string());
args.push(format!("{}x{}", selected.area.width, selected.area.height));
} else if mode == Mode::Area
&& let Some(selected) = selected_frame(ui, mode)
{
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> {
(mode == Mode::Window).then_some("x11")
}
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 mode_uses_frame(mode: Mode) -> bool {
matches!(mode, Mode::Window | Mode::Area)
}
fn recorder_mode_arg(mode: Mode) -> &'static str {
match mode {
Mode::Screen => "screen",
Mode::Window => "window",
Mode::Area => "area",
}
}
fn selected_frame(ui: &Ui, mode: Mode) -> Option<SelectedArea> {
(ui.selection_mode.get() == Some(mode))
.then(|| *ui.selected_area.borrow())
.flatten()
}
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 embedded_logo_is_packaged_as_a_png() {
assert!(APP_LOGO_BYTES.starts_with(b"\x89PNG\r\n\x1a\n"));
assert!(APP_LOGO_BYTES.len() > 100_000);
assert_eq!(
app_icon_path(Path::new("/tmp/ocula-data")),
PathBuf::from("/tmp/ocula-data/icons/hicolor/256x256/apps/dev.ocula.Ocula.png")
);
}
#[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 compact_record_action_tracks_primary_action() {
assert_eq!(
compact_record_action("START RECORDING"),
("media-record-symbolic", "Start recording")
);
assert_eq!(
compact_record_action("STOP & SAVE"),
("media-playback-stop-symbolic", "Stop and save recording")
);
assert_eq!(
compact_record_action("FORCE STOP"),
("process-stop-symbolic", "Force stop recording")
);
assert_eq!(
compact_record_action("TAKE SCREENSHOT"),
("camera-photo-symbolic", "Take screenshot")
);
}
#[test]
fn compact_status_style_tracks_recorder_state() {
assert_eq!(compact_status_class("STANDBY"), "ocula-ready");
assert_eq!(compact_status_class("REC 00:42"), "ocula-recording");
assert_eq!(compact_status_class("Saving..."), "ocula-recording");
assert_eq!(compact_status_class("Recording failed"), "ocula-warning");
}
#[test]
fn screenshot_output_uses_png_without_mutating_video_path() {
let root = temp_project_dir("screenshot-output");
std::fs::create_dir_all(&root).unwrap();
let video = root.join("capture.mp4");
assert_eq!(
screenshot_output_path(&video.to_string_lossy()).unwrap(),
root.join("capture.png")
);
std::fs::remove_dir_all(root).unwrap();
}
#[test]
fn screenshot_writer_emits_png_data() {
let root = temp_project_dir("screenshot-png");
let output = root.join("capture.png");
let image = PreviewImage {
data: vec![0; 16],
width: 2,
height: 2,
stride: 8,
};
write_screenshot_png(image, &output).unwrap();
assert!(
std::fs::read(&output)
.unwrap()
.starts_with(b"\x89PNG\r\n\x1a\n")
);
std::fs::remove_dir_all(root).unwrap();
}
#[test]
fn selection_cancellation_is_not_treated_as_failure() {
assert!(selection_was_cancelled(&anyhow::anyhow!(
"area selection cancelled"
)));
assert!(selection_was_cancelled(&anyhow::anyhow!(
"window selection cancelled"
)));
assert!(!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), Some("x11"));
}
#[test]
fn gui_window_mode_uses_a_direct_x11_window() {
assert!(mode_uses_frame(Mode::Window));
assert!(mode_uses_frame(Mode::Area));
assert!(!mode_uses_frame(Mode::Screen));
assert_eq!(recorder_mode_arg(Mode::Window), "window");
assert_eq!(recorder_mode_arg(Mode::Area), "area");
assert_eq!(recorder_mode_arg(Mode::Screen), "screen");
}
#[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_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 preview_image_becomes_a_cairo_snapshot() {
let image = PreviewImage {
data: vec![0; 8],
width: 2,
height: 1,
stride: 8,
};
let snapshot = image.into_snapshot().unwrap();
assert_eq!(snapshot.width, 2);
assert_eq!(snapshot.height, 1);
assert_eq!(snapshot.surface.stride(), 8);
}
#[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
})
}
}