From b04ea257ffd1b56884093449e887efc9040573ba Mon Sep 17 00:00:00 2001 From: Dave Patrick Caberto Date: Fri, 24 Nov 2023 18:33:14 +0800 Subject: [PATCH 01/77] chore: bump deps --- Cargo.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7558fae3..8f640221 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1382,18 +1382,18 @@ checksum = "836fa6a3e1e547f9a2c4040802ec865b5d85f4014efe00555d7090a3dcaa1090" [[package]] name = "serde" -version = "1.0.192" +version = "1.0.193" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bca2a08484b285dcb282d0f67b26cadc0df8b19f8c12502c13d966bf9482f001" +checksum = "25dd9975e68d0cb5aa1120c288333fc98731bd1dd12f561e468ea4728c042b89" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.192" +version = "1.0.193" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6c7207fbec9faa48073f3e3074cbe553af6ea512d7c21ba46e434e70ea9fbc1" +checksum = "43576ca501357b9b071ac53cdc7da8ef0cbd9493d8df094cd821777ea6e894d3" dependencies = [ "proc-macro2", "quote", From 8c1c9b0450263daf55aabae93963f2944571b123 Mon Sep 17 00:00:00 2001 From: Dave Patrick Caberto Date: Sat, 25 Nov 2023 19:19:22 +0800 Subject: [PATCH 02/77] feat: initial new UI impl --- build-aux/io.github.seadve.Kooha.Devel.json | 3 +- data/io.github.seadve.Kooha.gschema.xml.in | 15 + .../actions/circle-filled-symbolic.svg | 2 + .../icons/scalable/actions/crop-symbolic.svg | 2 + .../icons/scalable/actions/info-symbolic.svg | 1 + data/resources/resources.gresource.xml | 4 + data/resources/style.css | 8 + data/resources/ui/win.ui | 224 ++++++++++ src/application.rs | 14 +- src/area_selector/mod.rs | 4 +- src/area_selector/view_port.rs | 2 +- src/main.rs | 1 + src/win.rs | 407 ++++++++++++++++++ 13 files changed, 675 insertions(+), 12 deletions(-) create mode 100644 data/resources/icons/scalable/actions/circle-filled-symbolic.svg create mode 100644 data/resources/icons/scalable/actions/crop-symbolic.svg create mode 100644 data/resources/icons/scalable/actions/info-symbolic.svg create mode 100644 data/resources/ui/win.ui create mode 100644 src/win.rs diff --git a/build-aux/io.github.seadve.Kooha.Devel.json b/build-aux/io.github.seadve.Kooha.Devel.json index f02d2661..59f02214 100644 --- a/build-aux/io.github.seadve.Kooha.Devel.json +++ b/build-aux/io.github.seadve.Kooha.Devel.json @@ -17,8 +17,7 @@ "--env=RUST_BACKTRACE=1", "--env=RUST_LOG=kooha=debug", "--env=G_MESSAGES_DEBUG=none", - "--env=KOOHA_EXPERIMENTAL=1", - "--env=GST_DEBUG=3" + "--env=KOOHA_EXPERIMENTAL=1" ], "build-options": { "append-path": "/usr/lib/sdk/llvm16/bin:/usr/lib/sdk/rust-stable/bin", diff --git a/data/io.github.seadve.Kooha.gschema.xml.in b/data/io.github.seadve.Kooha.gschema.xml.in index 37621cca..64f757be 100644 --- a/data/io.github.seadve.Kooha.gschema.xml.in +++ b/data/io.github.seadve.Kooha.gschema.xml.in @@ -1,6 +1,21 @@ + + 1000 + Window width + + + + 600 + Window height + + + + false + Whether the window is maximized + + diff --git a/data/resources/icons/scalable/actions/circle-filled-symbolic.svg b/data/resources/icons/scalable/actions/circle-filled-symbolic.svg new file mode 100644 index 00000000..021946b2 --- /dev/null +++ b/data/resources/icons/scalable/actions/circle-filled-symbolic.svg @@ -0,0 +1,2 @@ + + diff --git a/data/resources/icons/scalable/actions/crop-symbolic.svg b/data/resources/icons/scalable/actions/crop-symbolic.svg new file mode 100644 index 00000000..fee01693 --- /dev/null +++ b/data/resources/icons/scalable/actions/crop-symbolic.svg @@ -0,0 +1,2 @@ + + diff --git a/data/resources/icons/scalable/actions/info-symbolic.svg b/data/resources/icons/scalable/actions/info-symbolic.svg new file mode 100644 index 00000000..65d5d5d1 --- /dev/null +++ b/data/resources/icons/scalable/actions/info-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/data/resources/resources.gresource.xml b/data/resources/resources.gresource.xml index eeeb094c..dba9c7ce 100644 --- a/data/resources/resources.gresource.xml +++ b/data/resources/resources.gresource.xml @@ -4,6 +4,9 @@ icons/scalable/actions/audio-volume-high-symbolic.svg icons/scalable/actions/audio-volume-muted-symbolic.svg icons/scalable/actions/checkmark-symbolic.svg + icons/scalable/actions/circle-filled-symbolic.svg + icons/scalable/actions/crop-symbolic.svg + icons/scalable/actions/info-symbolic.svg icons/scalable/actions/microphone-disabled-symbolic.svg icons/scalable/actions/microphone2-symbolic.svg icons/scalable/actions/mouse-wireless-disabled-symbolic.svg @@ -16,6 +19,7 @@ ui/area-selector.ui ui/preferences-window.ui ui/shortcuts.ui + ui/win.ui ui/window.ui diff --git a/data/resources/style.css b/data/resources/style.css index 986199d9..7590fed8 100644 --- a/data/resources/style.css +++ b/data/resources/style.css @@ -1,3 +1,11 @@ +.view-port { + background-color: #3d3846; +} + +.red { + color: #e01b24; +} + row.error-view { padding: 0px; } diff --git a/data/resources/ui/win.ui b/data/resources/ui/win.ui new file mode 100644 index 00000000..19cb17b3 --- /dev/null +++ b/data/resources/ui/win.ui @@ -0,0 +1,224 @@ + + + +
+ + _Preferences + app.show-preferences + + + _Keyboard Shortcuts + win.show-help-overlay + + + _About Kooha + app.show-about + +
+
+ + + + + + + +
diff --git a/src/application.rs b/src/application.rs index 037aea80..202c2e5b 100644 --- a/src/application.rs +++ b/src/application.rs @@ -11,7 +11,7 @@ use crate::{ config::{APP_ID, PKGDATADIR, PROFILE, VERSION}, preferences_window::PreferencesWindow, settings::Settings, - window::Window, + win::Win, }; mod imp { @@ -21,7 +21,7 @@ mod imp { #[derive(Debug, Default)] pub struct Application { - pub(super) window: OnceCell>, + pub(super) window: OnceCell>, pub(super) settings: OnceCell, } @@ -46,7 +46,7 @@ mod imp { return; } - let window = Window::new(&obj); + let window = Win::new(&obj); self.window.set(window.downgrade()).unwrap(); window.present(); } @@ -95,7 +95,7 @@ impl Application { }) } - pub fn window(&self) -> Window { + pub fn window(&self) -> Win { self.imp() .window .get() @@ -144,7 +144,7 @@ impl Application { if !err.matches(gio::IOErrorEnum::Cancelled) { tracing::error!("Failed to launch default for uri `{}`: {:?}", uri, err); - self.window().present_error(&err.into()); + // self.window().present_error(&err.into()); } } } @@ -196,9 +196,7 @@ impl Application { let action_quit = gio::SimpleAction::new("quit", None); action_quit.connect_activate(clone!(@weak self as obj => move |_, _| { if let Some(window) = obj.imp().window.get().and_then(|window| window.upgrade()) { - if let Err(err) = window.close() { - tracing::warn!("Failed to close window: {:?}", err); - } + window.close(); } obj.quit(); })); diff --git a/src/area_selector/mod.rs b/src/area_selector/mod.rs index f55f0a83..e7527711 100644 --- a/src/area_selector/mod.rs +++ b/src/area_selector/mod.rs @@ -13,12 +13,14 @@ use gtk::{ use std::{cell::RefCell, os::unix::prelude::RawFd}; -use self::view_port::{Selection, ViewPort}; +use self::view_port::Selection; use crate::{cancelled::Cancelled, pipeline, screencast_session::Stream}; const PREVIEW_FRAMERATE: u32 = 60; const ASSUMED_HEADER_BAR_HEIGHT: f64 = 47.0; +pub use self::view_port::ViewPort; + #[derive(Debug)] pub struct Data { /// Selection relative to paintable_rect diff --git a/src/area_selector/view_port.rs b/src/area_selector/view_port.rs index d1c3c7ee..d8fec537 100644 --- a/src/area_selector/view_port.rs +++ b/src/area_selector/view_port.rs @@ -172,7 +172,7 @@ mod imp { fn measure(&self, orientation: gtk::Orientation, for_size: i32) -> (i32, i32, i32, i32) { if for_size == 0 { - return (0, 0, 0, 0); + return (0, 0, -1, -1); } let Some(paintable) = self.obj().paintable() else { diff --git a/src/main.rs b/src/main.rs index c05eef05..2adf8106 100644 --- a/src/main.rs +++ b/src/main.rs @@ -41,6 +41,7 @@ mod settings; mod timer; mod toggle_button; mod utils; +mod win; mod window; use gettextrs::{gettext, LocaleCategory}; diff --git a/src/win.rs b/src/win.rs new file mode 100644 index 00000000..3ec4a720 --- /dev/null +++ b/src/win.rs @@ -0,0 +1,407 @@ +use adw::{prelude::*, subclass::prelude::*}; +use anyhow::{Context, Result}; +use gst::prelude::*; +use gtk::{ + gdk, gio, + glib::{self, clone}, +}; + +use crate::{ + application::Application, + area_selector::ViewPort, + audio_device::{self, Class as AudioDeviceClass}, + config::PROFILE, + pipeline, + screencast_session::{CursorMode, PersistMode, ScreencastSession, SourceType}, + toggle_button::ToggleButton, + utils, +}; + +mod imp { + use std::cell::RefCell; + + use gst::bus::BusWatchGuard; + + use super::*; + + #[derive(Default, gtk::CompositeTemplate)] + #[template(resource = "/io/github/seadve/Kooha/ui/win.ui")] + pub struct Win { + #[template_child] + pub(super) view_port: TemplateChild, + #[template_child] + pub(super) desktop_audio_level: TemplateChild, + #[template_child] + pub(super) microphone_level: TemplateChild, + + pub(super) desktop_audio_pipeline: RefCell>, + pub(super) microphone_pipeline: RefCell>, + + pub(super) session: RefCell>, + } + + #[glib::object_subclass] + impl ObjectSubclass for Win { + const NAME: &'static str = "KoohaWin"; + type Type = super::Win; + type ParentType = adw::ApplicationWindow; + + fn class_init(klass: &mut Self::Class) { + ToggleButton::ensure_type(); + + klass.bind_template(); + + klass.install_action_async("win.select-video-source", None, |obj, _, _| async move { + if let Err(err) = obj.replace_session(None).await { + tracing::error!("Failed to replace session: {:?}", err); + } + }); + } + + fn instance_init(obj: &glib::subclass::InitializingObject) { + obj.init_template(); + } + } + + impl ObjectImpl for Win { + fn constructed(&self) { + self.parent_constructed(); + + let obj = self.obj(); + + if PROFILE == "Devel" { + obj.add_css_class("devel"); + } + + obj.setup_settings(); + + obj.load_window_size(); + + glib::spawn_future_local(clone!(@weak obj => async move { + if let Err(err) = obj.load_session().await { + tracing::error!("Failed to load session: {:?}", err); + } + })); + + obj.update_desktop_audio_pipeline(); + obj.update_microphone_pipeline(); + } + + fn dispose(&self) { + if let Some((pipeline, _)) = self.desktop_audio_pipeline.take() { + let _ = pipeline.set_state(gst::State::Null); + } + + if let Some((pipeline, _)) = self.microphone_pipeline.take() { + let _ = pipeline.set_state(gst::State::Null); + } + + self.dispose_template(); + } + } + + impl WidgetImpl for Win {} + + impl WindowImpl for Win { + fn close_request(&self) -> glib::Propagation { + let obj = self.obj(); + + if let Err(err) = obj.save_window_size() { + tracing::warn!("Failed to save window state, {:?}", &err); + } + + self.parent_close_request() + } + } + + impl ApplicationWindowImpl for Win {} + impl AdwApplicationWindowImpl for Win {} +} + +glib::wrapper! { + pub struct Win(ObjectSubclass) + @extends gtk::Widget, gtk::Window, gtk::ApplicationWindow, adw::ApplicationWindow, + @implements gio::ActionMap, gio::ActionGroup, gtk::Native; +} + +impl Win { + pub fn new(application: &Application) -> Self { + glib::Object::builder() + .property("application", application) + .build() + } + + async fn replace_session(&self, restore_token: Option<&str>) -> Result<()> { + let imp = self.imp(); + + let app = utils::app_instance(); + let settings = app.settings(); + + let session = ScreencastSession::new() + .await + .context("Failed to create ScreencastSession")?; + + tracing::debug!( + version = ?session.version(), + available_cursor_modes = ?session.available_cursor_modes(), + available_source_types = ?session.available_source_types(), + "Screencast session created" + ); + + let (streams, restore_token, fd) = session + .begin( + if settings.show_pointer() { + CursorMode::EMBEDDED + } else { + CursorMode::HIDDEN + }, + if utils::is_experimental_mode() { + SourceType::MONITOR | SourceType::WINDOW + } else { + SourceType::MONITOR + }, + true, + restore_token, + PersistMode::ExplicitlyRevoked, + Some(self), + ) + .await + .context("Failed to begin ScreencastSession")?; + imp.session.replace(Some(session)); + settings.set_screencast_restore_token(&restore_token.unwrap_or_default()); + + let pipeline = gst::Pipeline::new(); + let videosrc_bin = pipeline::pipewiresrc_bin(fd, &streams, 30, None)?; + let audioconvert = gst::ElementFactory::make("videoconvert").build()?; + let sink = gst::ElementFactory::make("gtk4paintablesink").build()?; + pipeline.add_many([videosrc_bin.upcast_ref(), &audioconvert, &sink])?; + gst::Element::link_many([videosrc_bin.upcast_ref(), &audioconvert, &sink])?; + + let paintable = sink.property::("paintable"); + imp.view_port.set_paintable(Some(paintable)); + + pipeline.set_state(gst::State::Playing)?; + + Ok(()) + } + + async fn load_session(&self) -> Result<()> { + let app = utils::app_instance(); + let settings = app.settings(); + + let restore_token = settings.screencast_restore_token(); + settings.set_screencast_restore_token(""); + + self.replace_session(Some(&restore_token)).await?; + + Ok(()) + } + + async fn load_desktop_audio(&self) -> Result<()> { + let imp = self.imp(); + + let device_name = audio_device::find_default_name(AudioDeviceClass::Sink) + .await + .context("No desktop audio source found")?; + + let pulsesrc = gst::ElementFactory::make("pulsesrc").build()?; + let audioconvert = gst::ElementFactory::make("audioconvert").build()?; + let level = gst::ElementFactory::make("level") + .property("interval", gst::ClockTime::from_mseconds(80)) + .property("peak-ttl", gst::ClockTime::from_mseconds(80)) + .build()?; + let fakesink = gst::ElementFactory::make("fakesink").build()?; + + pulsesrc.set_property("device", device_name); + fakesink.set_property("sync", false); + + let pipeline = gst::Pipeline::new(); + pipeline.add_many([&pulsesrc, &audioconvert, &level, &fakesink])?; + gst::Element::link_many([&pulsesrc, &audioconvert, &level, &fakesink])?; + + let bus = pipeline.bus().unwrap(); + let bus_watch_guard = bus.add_watch_local( + clone!(@weak self as obj => @default-panic, move |_, message| { + handle_level_message(message, |peak| { + obj.imp().desktop_audio_level.set_value(peak); + }) + }), + )?; + + pipeline.set_state(gst::State::Playing)?; + imp.desktop_audio_pipeline + .replace(Some((pipeline, bus_watch_guard))); + + Ok(()) + } + + async fn load_microphone(&self) -> Result<()> { + let imp = self.imp(); + + let device_name = audio_device::find_default_name(AudioDeviceClass::Source) + .await + .context("No microphone source found")?; + + let pulsesrc = gst::ElementFactory::make("pulsesrc").build()?; + let audioconvert = gst::ElementFactory::make("audioconvert").build()?; + let level = gst::ElementFactory::make("level") + .property("interval", gst::ClockTime::from_mseconds(80)) + .property("peak-ttl", gst::ClockTime::from_mseconds(80)) + .build()?; + let fakesink = gst::ElementFactory::make("fakesink").build()?; + + pulsesrc.set_property("device", device_name); + + let pipeline = gst::Pipeline::new(); + pipeline.add_many([&pulsesrc, &audioconvert, &level, &fakesink])?; + gst::Element::link_many([&pulsesrc, &audioconvert, &level, &fakesink])?; + + let bus = pipeline.bus().unwrap(); + let bus_watch_guard = bus.add_watch_local( + clone!(@weak self as obj => @default-panic, move |_, message| { + handle_level_message(message, |peak| { + obj.imp().microphone_level.set_value(peak); + }) + }), + )?; + + pipeline.set_state(gst::State::Playing)?; + imp.microphone_pipeline + .replace(Some((pipeline, bus_watch_guard))); + + Ok(()) + } + + fn load_window_size(&self) { + let app = utils::app_instance(); + let settings = app.settings(); + + self.set_default_size(settings.window_width(), settings.window_height()); + + if settings.window_maximized() { + self.maximize(); + } + } + + fn save_window_size(&self) -> Result<()> { + let app = utils::app_instance(); + let settings = app.settings(); + + let (width, height) = self.default_size(); + + settings.try_set_window_width(width)?; + settings.try_set_window_height(height)?; + + settings.try_set_window_maximized(self.is_maximized())?; + + Ok(()) + } + + fn update_desktop_audio_pipeline(&self) { + let imp = self.imp(); + + let app = utils::app_instance(); + let settings = app.settings(); + + if settings.record_speaker() && imp.desktop_audio_pipeline.borrow().is_none() { + glib::spawn_future_local(clone!(@weak self as obj => async move { + if let Err(err) = obj.load_desktop_audio().await { + tracing::error!("Failed to load desktop audio: {:?}", err); + } + })); + } else if let Some((pipeline, _)) = imp.desktop_audio_pipeline.take() { + let _ = pipeline.set_state(gst::State::Null); + imp.desktop_audio_level.set_value(0.0); + } + } + + fn update_microphone_pipeline(&self) { + let imp = self.imp(); + + let app = utils::app_instance(); + let settings = app.settings(); + + if settings.record_mic() && imp.microphone_pipeline.borrow().is_none() { + glib::spawn_future_local(clone!(@weak self as obj => async move { + if let Err(err) = obj.load_microphone().await { + tracing::error!("Failed to load microphone: {:?}", err); + } + })); + } else if let Some((pipeline, _)) = imp.microphone_pipeline.take() { + let _ = pipeline.set_state(gst::State::Null); + imp.microphone_level.set_value(0.0); + } + } + + fn update_desktop_audio_level_sensitivity(&self) { + let app = utils::app_instance(); + let settings = app.settings(); + + self.imp() + .desktop_audio_level + .set_sensitive(settings.record_speaker()); + } + + fn update_microphone_level_sensitivity(&self) { + let app = utils::app_instance(); + let settings = app.settings(); + + self.imp() + .microphone_level + .set_sensitive(settings.record_mic()); + } + + fn setup_settings(&self) { + let app = utils::app_instance(); + let settings = app.settings(); + + self.add_action(&settings.create_record_speaker_action()); + self.add_action(&settings.create_record_mic_action()); + self.add_action(&settings.create_show_pointer_action()); + + settings.connect_record_speaker_changed(clone!(@weak self as obj => move |_| { + obj.update_desktop_audio_level_sensitivity(); + obj.update_desktop_audio_pipeline(); + })); + + settings.connect_record_mic_changed(clone!(@weak self as obj => move |_| { + obj.update_microphone_level_sensitivity(); + obj.update_microphone_pipeline(); + })); + + self.update_desktop_audio_level_sensitivity(); + self.update_microphone_level_sensitivity(); + } +} + +fn handle_level_message(message: &gst::Message, callback: impl Fn(f64)) -> glib::ControlFlow { + match message.view() { + gst::MessageView::Element(e) => { + if let Some(structure) = e.structure() { + if structure.has_name("level") { + let peak = structure + .get::<&glib::ValueArray>("peak") + .unwrap() + .first() + .unwrap() + .get::() + .unwrap(); + let normalized_peak = 10_f64.powf(peak / 20.0); + callback(normalized_peak); + } + } + + glib::ControlFlow::Continue + } + gst::MessageView::Error(e) => { + tracing::error!(src = ?e.src(), error = ?e.error(), debug = ?e.debug(), "Error from audio bus"); + + glib::ControlFlow::Break + } + _ => { + tracing::trace!(?message, "Message from audio bus"); + + glib::ControlFlow::Continue + } + } +} From 8ac215ff79cd1233c01de3b33f20d1d40854c62b Mon Sep 17 00:00:00 2001 From: Dave Patrick Caberto Date: Sat, 25 Nov 2023 19:28:29 +0800 Subject: [PATCH 03/77] fix: use max of channels --- src/win.rs | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/src/win.rs b/src/win.rs index 3ec4a720..7a96a4af 100644 --- a/src/win.rs +++ b/src/win.rs @@ -379,15 +379,12 @@ fn handle_level_message(message: &gst::Message, callback: impl Fn(f64)) -> glib: gst::MessageView::Element(e) => { if let Some(structure) = e.structure() { if structure.has_name("level") { - let peak = structure - .get::<&glib::ValueArray>("peak") - .unwrap() - .first() - .unwrap() - .get::() - .unwrap(); - let normalized_peak = 10_f64.powf(peak / 20.0); - callback(normalized_peak); + let peaks = structure.get::<&glib::ValueArray>("rms").unwrap(); + let left_peak = peaks.nth(0).unwrap().get::().unwrap(); + let right_peak = peaks.nth(1).unwrap().get::().unwrap(); + let max_peak = left_peak.max(right_peak); + let normalized_max_peak = 10_f64.powf(max_peak / 20.0); + callback(normalized_max_peak); } } From 37c5641c76cbb9fdf55e9ac1744e1b8b070f4a70 Mon Sep 17 00:00:00 2001 From: Dave Patrick Caberto Date: Sat, 25 Nov 2023 19:34:39 +0800 Subject: [PATCH 04/77] misc: drop useless separator --- data/resources/ui/win.ui | 3 --- 1 file changed, 3 deletions(-) diff --git a/data/resources/ui/win.ui b/data/resources/ui/win.ui index 19cb17b3..68b3a3f9 100644 --- a/data/resources/ui/win.ui +++ b/data/resources/ui/win.ui @@ -52,9 +52,6 @@ - - - 12 From 0b385b671deef08387917b1e30abd4e76f8c5860 Mon Sep 17 00:00:00 2001 From: Dave Patrick Caberto Date: Sat, 25 Nov 2023 20:25:36 +0800 Subject: [PATCH 05/77] feat: working selection toggle and info label --- data/resources/ui/win.ui | 9 +- src/area_selector/mod.rs | 5 +- src/area_selector/view_port.rs | 15 +-- src/win.rs | 163 ++++++++++++++++++++++++++++++--- 4 files changed, 159 insertions(+), 33 deletions(-) diff --git a/data/resources/ui/win.ui b/data/resources/ui/win.ui index 68b3a3f9..8ff476d0 100644 --- a/data/resources/ui/win.ui +++ b/data/resources/ui/win.ui @@ -70,12 +70,13 @@ start - Select Source + Select Source win.select-video-source - + + Toggle Selection crop-symbolic diff --git a/src/area_selector/mod.rs b/src/area_selector/mod.rs index e7527711..0fc74ff0 100644 --- a/src/area_selector/mod.rs +++ b/src/area_selector/mod.rs @@ -13,13 +13,12 @@ use gtk::{ use std::{cell::RefCell, os::unix::prelude::RawFd}; -use self::view_port::Selection; use crate::{cancelled::Cancelled, pipeline, screencast_session::Stream}; const PREVIEW_FRAMERATE: u32 = 60; const ASSUMED_HEADER_BAR_HEIGHT: f64 = 47.0; -pub use self::view_port::ViewPort; +pub use self::view_port::{Selection, ViewPort}; #[derive(Debug)] pub struct Data { @@ -91,7 +90,7 @@ mod imp { }); klass.install_action("area-selector.reset", None, move |obj, _, _| { - obj.imp().view_port.reset_selection(); + obj.imp().view_port.set_selection(None); }); klass.add_binding_action( diff --git a/src/area_selector/view_port.rs b/src/area_selector/view_port.rs index d8fec537..7ea4f5fc 100644 --- a/src/area_selector/view_port.rs +++ b/src/area_selector/view_port.rs @@ -361,14 +361,10 @@ impl ViewPort { self.imp().paintable_rect.get() } - pub fn reset_selection(&self) { - self.set_selection(None); + pub fn set_selection(&self, selection: Option) { + self.imp().selection.set(selection); self.update_selection_handles(); self.queue_draw(); - } - - fn set_selection(&self, selection: Option) { - self.imp().selection.set(selection); self.notify_selection(); } @@ -424,7 +420,6 @@ impl ViewPort { end_x: x, end_y: y, })); - self.update_selection_handles(); } else { imp.drag_cursor.set(cursor_type); imp.drag_start.set(Some(Point::new(x as f32, y as f32))); @@ -469,8 +464,6 @@ impl ViewPort { self.set_selection(Some(new_selection)); } - - self.queue_draw(); } fn on_drag_update(&self, _gesture: >k::GestureDrag, _: f64, _: f64) { @@ -630,9 +623,6 @@ impl ViewPort { imp.drag_start .set(Some(Point::new(drag_start.x() + dx, drag_start.y() + dy))); } - - self.update_selection_handles(); - self.queue_draw(); } fn on_drag_end(&self, _gesture: >k::GestureDrag, dx: f64, dy: f64) { @@ -674,7 +664,6 @@ impl ViewPort { } self.set_selection(Some(selection)); - self.update_selection_handles(); } } diff --git a/src/win.rs b/src/win.rs index 7a96a4af..352f89a5 100644 --- a/src/win.rs +++ b/src/win.rs @@ -1,5 +1,6 @@ use adw::{prelude::*, subclass::prelude::*}; use anyhow::{Context, Result}; +use gettextrs::gettext; use gst::prelude::*; use gtk::{ gdk, gio, @@ -8,7 +9,7 @@ use gtk::{ use crate::{ application::Application, - area_selector::ViewPort, + area_selector::{Selection, ViewPort}, audio_device::{self, Class as AudioDeviceClass}, config::PROFILE, pipeline, @@ -18,7 +19,7 @@ use crate::{ }; mod imp { - use std::cell::RefCell; + use std::cell::{Cell, RefCell}; use gst::bus::BusWatchGuard; @@ -30,14 +31,21 @@ mod imp { #[template_child] pub(super) view_port: TemplateChild, #[template_child] + pub(super) selection_toggle: TemplateChild, + #[template_child] pub(super) desktop_audio_level: TemplateChild, #[template_child] pub(super) microphone_level: TemplateChild, + #[template_child] + pub(super) info_label: TemplateChild, + + pub(super) session: RefCell>, + pub(super) stream_size: Cell>, + + pub(super) previous_selection: RefCell>, pub(super) desktop_audio_pipeline: RefCell>, pub(super) microphone_pipeline: RefCell>, - - pub(super) session: RefCell>, } #[glib::object_subclass] @@ -75,6 +83,24 @@ mod imp { obj.setup_settings(); + self.selection_toggle + .connect_active_notify(clone!(@weak obj => move |toggle| { + if toggle.is_active() { + let prev_selection = *obj.imp().previous_selection.borrow(); + obj.imp().view_port.set_selection(prev_selection); + } else { + obj.imp().view_port.set_selection(None); + } + })); + self.view_port + .connect_selection_notify(clone!(@weak obj => move |view_port| { + if let Some(selection) = view_port.selection() { + obj.imp().previous_selection.replace(Some(selection)); + } + obj.update_selection_ui(); + obj.update_info_label(); + })); + obj.load_window_size(); glib::spawn_future_local(clone!(@weak obj => async move { @@ -83,11 +109,17 @@ mod imp { } })); + obj.update_selection_ui(); + obj.update_info_label(); obj.update_desktop_audio_pipeline(); obj.update_microphone_pipeline(); } fn dispose(&self) { + if let Some((_, pipeline, _)) = self.session.take() { + let _ = pipeline.set_state(gst::State::Null); + } + if let Some((pipeline, _)) = self.desktop_audio_pipeline.take() { let _ = pipeline.set_state(gst::State::Null); } @@ -137,6 +169,9 @@ impl Win { let app = utils::app_instance(); let settings = app.settings(); + imp.stream_size.set(None); + self.update_info_label(); + let session = ScreencastSession::new() .await .context("Failed to create ScreencastSession")?; @@ -167,21 +202,35 @@ impl Win { ) .await .context("Failed to begin ScreencastSession")?; - imp.session.replace(Some(session)); settings.set_screencast_restore_token(&restore_token.unwrap_or_default()); let pipeline = gst::Pipeline::new(); let videosrc_bin = pipeline::pipewiresrc_bin(fd, &streams, 30, None)?; - let audioconvert = gst::ElementFactory::make("videoconvert").build()?; + let audioconvert = gst::ElementFactory::make("videoconvert") + .name("sink-videoconvert") + .build()?; let sink = gst::ElementFactory::make("gtk4paintablesink").build()?; pipeline.add_many([videosrc_bin.upcast_ref(), &audioconvert, &sink])?; gst::Element::link_many([videosrc_bin.upcast_ref(), &audioconvert, &sink])?; + let bus_watch_guard = pipeline.bus().unwrap().add_watch_local( + clone!(@weak self as obj => @default-panic, move |_, message| { + obj.handle_video_bus_message(message) + }), + )?; + imp.session + .replace(Some((session, pipeline, bus_watch_guard))); + + imp.session + .borrow() + .as_ref() + .map(|(_, pipeline, _)| pipeline) + .unwrap() + .set_state(gst::State::Playing)?; + let paintable = sink.property::("paintable"); imp.view_port.set_paintable(Some(paintable)); - pipeline.set_state(gst::State::Playing)?; - Ok(()) } @@ -222,16 +271,22 @@ impl Win { let bus = pipeline.bus().unwrap(); let bus_watch_guard = bus.add_watch_local( clone!(@weak self as obj => @default-panic, move |_, message| { - handle_level_message(message, |peak| { + handle_audio_bus_message(message, |peak| { obj.imp().desktop_audio_level.set_value(peak); }) }), )?; - pipeline.set_state(gst::State::Playing)?; imp.desktop_audio_pipeline .replace(Some((pipeline, bus_watch_guard))); + imp.desktop_audio_pipeline + .borrow() + .as_ref() + .map(|(pipeline, _)| pipeline) + .unwrap() + .set_state(gst::State::Playing)?; + Ok(()) } @@ -259,16 +314,22 @@ impl Win { let bus = pipeline.bus().unwrap(); let bus_watch_guard = bus.add_watch_local( clone!(@weak self as obj => @default-panic, move |_, message| { - handle_level_message(message, |peak| { + handle_audio_bus_message(message, |peak| { obj.imp().microphone_level.set_value(peak); }) }), )?; - pipeline.set_state(gst::State::Playing)?; imp.microphone_pipeline .replace(Some((pipeline, bus_watch_guard))); + imp.microphone_pipeline + .borrow() + .as_ref() + .map(|(pipeline, _)| pipeline) + .unwrap() + .set_state(gst::State::Playing)?; + Ok(()) } @@ -297,6 +358,45 @@ impl Win { Ok(()) } + fn handle_video_bus_message(&self, message: &gst::Message) -> glib::ControlFlow { + let imp = self.imp(); + + match message.view() { + gst::MessageView::AsyncDone(_) => { + let videoconvert = imp + .session + .borrow() + .as_ref() + .map(|(_, pipeline, _)| pipeline) + .unwrap() + .by_name("sink-videoconvert") + .unwrap(); + let caps = videoconvert + .static_pad("src") + .unwrap() + .current_caps() + .unwrap(); + let caps_struct = caps.structure(0).unwrap(); + let stream_width = caps_struct.get::("width").unwrap(); + let stream_height = caps_struct.get::("height").unwrap(); + imp.stream_size.set(Some((stream_width, stream_height))); + self.update_info_label(); + + glib::ControlFlow::Continue + } + gst::MessageView::Error(e) => { + tracing::error!(src = ?e.src(), error = ?e.error(), debug = ?e.debug(), "Error from video bus"); + + glib::ControlFlow::Break + } + _ => { + tracing::trace!(?message, "Message from video bus"); + + glib::ControlFlow::Continue + } + } + } + fn update_desktop_audio_pipeline(&self) { let imp = self.imp(); @@ -333,6 +433,43 @@ impl Win { } } + fn update_selection_ui(&self) { + let imp = self.imp(); + + imp.selection_toggle + .set_active(imp.view_port.selection().is_some()); + } + + fn update_info_label(&self) { + let imp = self.imp(); + + let mut info_list = vec!["WebM".to_string(), "30 FPS".to_string()]; + + match (imp.stream_size.get(), imp.view_port.selection()) { + (Some(stream_size), Some(selection)) => { + let paintable_rect = imp.view_port.paintable_rect().unwrap(); + + let (stream_width, stream_height) = stream_size; + let scale_factor_h = stream_width as f32 / paintable_rect.width(); + let scale_factor_v = stream_height as f32 / paintable_rect.height(); + + let selection_rect_scaled = selection.rect().scale(scale_factor_h, scale_factor_v); + info_list.push(format!( + "{} {}×{}", + gettext("approx."), + selection_rect_scaled.width().round() as i32, + selection_rect_scaled.height().round() as i32, + )); + } + (Some(stream_size), None) => { + info_list.push(format!("{}×{}", stream_size.0, stream_size.1)); + } + _ => {} + } + + imp.info_label.set_label(&info_list.join(" • ")); + } + fn update_desktop_audio_level_sensitivity(&self) { let app = utils::app_instance(); let settings = app.settings(); @@ -374,7 +511,7 @@ impl Win { } } -fn handle_level_message(message: &gst::Message, callback: impl Fn(f64)) -> glib::ControlFlow { +fn handle_audio_bus_message(message: &gst::Message, callback: impl Fn(f64)) -> glib::ControlFlow { match message.view() { gst::MessageView::Element(e) => { if let Some(structure) = e.structure() { From 76ebe21e7515f29a08165aba5b9ac334d2f4d2be Mon Sep 17 00:00:00 2001 From: Dave Patrick Caberto Date: Sat, 25 Nov 2023 20:29:46 +0800 Subject: [PATCH 06/77] feat: wire up framerate to info label --- src/win.rs | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/src/win.rs b/src/win.rs index 352f89a5..78f0aba7 100644 --- a/src/win.rs +++ b/src/win.rs @@ -443,26 +443,28 @@ impl Win { fn update_info_label(&self) { let imp = self.imp(); - let mut info_list = vec!["WebM".to_string(), "30 FPS".to_string()]; + let app = utils::app_instance(); + let settings = app.settings(); + + let mut info_list = vec![ + "WebM".to_string(), + format!("{} FPS", settings.video_framerate()), + ]; match (imp.stream_size.get(), imp.view_port.selection()) { - (Some(stream_size), Some(selection)) => { + (Some((stream_width, stream_height)), Some(selection)) => { let paintable_rect = imp.view_port.paintable_rect().unwrap(); - - let (stream_width, stream_height) = stream_size; let scale_factor_h = stream_width as f32 / paintable_rect.width(); let scale_factor_v = stream_height as f32 / paintable_rect.height(); - let selection_rect_scaled = selection.rect().scale(scale_factor_h, scale_factor_v); info_list.push(format!( - "{} {}×{}", - gettext("approx."), + "{}×{}", selection_rect_scaled.width().round() as i32, selection_rect_scaled.height().round() as i32, )); } - (Some(stream_size), None) => { - info_list.push(format!("{}×{}", stream_size.0, stream_size.1)); + (Some((stream_width, stream_height)), None) => { + info_list.push(format!("{}×{}", stream_width, stream_height)); } _ => {} } @@ -506,6 +508,10 @@ impl Win { obj.update_microphone_pipeline(); })); + settings.connect_video_framerate_changed(clone!(@weak self as obj => move |_| { + obj.update_info_label(); + })); + self.update_desktop_audio_level_sensitivity(); self.update_microphone_level_sensitivity(); } From 0b2ab5030ef7014d9e6a025ba7614831113c28fd Mon Sep 17 00:00:00 2001 From: Dave Patrick Caberto Date: Sat, 25 Nov 2023 20:35:00 +0800 Subject: [PATCH 07/77] feat: wire up format name to info label --- src/win.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/win.rs b/src/win.rs index 78f0aba7..8a4d0818 100644 --- a/src/win.rs +++ b/src/win.rs @@ -447,7 +447,9 @@ impl Win { let settings = app.settings(); let mut info_list = vec![ - "WebM".to_string(), + settings + .profile() + .map_or_else(|| gettext("No Profile"), |profile| profile.name()), format!("{} FPS", settings.video_framerate()), ]; @@ -502,7 +504,6 @@ impl Win { obj.update_desktop_audio_level_sensitivity(); obj.update_desktop_audio_pipeline(); })); - settings.connect_record_mic_changed(clone!(@weak self as obj => move |_| { obj.update_microphone_level_sensitivity(); obj.update_microphone_pipeline(); @@ -511,6 +512,9 @@ impl Win { settings.connect_video_framerate_changed(clone!(@weak self as obj => move |_| { obj.update_info_label(); })); + settings.connect_profile_changed(clone!(@weak self as obj => move |_| { + obj.update_info_label(); + })); self.update_desktop_audio_level_sensitivity(); self.update_microphone_level_sensitivity(); From 7288e518894ff2112cb7820b97bf1aa50158227f Mon Sep 17 00:00:00 2001 From: Dave Patrick Caberto Date: Sat, 25 Nov 2023 20:40:44 +0800 Subject: [PATCH 08/77] fix: set pipeline state to null when changing session --- src/win.rs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/win.rs b/src/win.rs index 8a4d0818..4b2d6a9f 100644 --- a/src/win.rs +++ b/src/win.rs @@ -169,9 +169,6 @@ impl Win { let app = utils::app_instance(); let settings = app.settings(); - imp.stream_size.set(None); - self.update_info_label(); - let session = ScreencastSession::new() .await .context("Failed to create ScreencastSession")?; @@ -218,6 +215,14 @@ impl Win { obj.handle_video_bus_message(message) }), )?; + + imp.stream_size.set(None); + self.update_info_label(); + + if let Some((_, pipeline, _)) = imp.session.take() { + let _ = pipeline.set_state(gst::State::Null); + } + imp.session .replace(Some((session, pipeline, bus_watch_guard))); From 958d539ba0c66222dea87207b3ba6bb1d467e64f Mon Sep 17 00:00:00 2001 From: Dave Patrick Caberto Date: Sat, 25 Nov 2023 20:44:17 +0800 Subject: [PATCH 09/77] fix: initially disable selection toggle sensitivity --- src/win.rs | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/src/win.rs b/src/win.rs index 4b2d6a9f..ab116cce 100644 --- a/src/win.rs +++ b/src/win.rs @@ -42,7 +42,7 @@ mod imp { pub(super) session: RefCell>, pub(super) stream_size: Cell>, - pub(super) previous_selection: RefCell>, + pub(super) previous_selection: Cell>, pub(super) desktop_audio_pipeline: RefCell>, pub(super) microphone_pipeline: RefCell>, @@ -86,7 +86,7 @@ mod imp { self.selection_toggle .connect_active_notify(clone!(@weak obj => move |toggle| { if toggle.is_active() { - let prev_selection = *obj.imp().previous_selection.borrow(); + let prev_selection = obj.imp().previous_selection.get(); obj.imp().view_port.set_selection(prev_selection); } else { obj.imp().view_port.set_selection(None); @@ -96,8 +96,9 @@ mod imp { .connect_selection_notify(clone!(@weak obj => move |view_port| { if let Some(selection) = view_port.selection() { obj.imp().previous_selection.replace(Some(selection)); + obj.update_selection_toggle_sensitivity(); } - obj.update_selection_ui(); + obj.update_selection_toggle(); obj.update_info_label(); })); @@ -109,7 +110,8 @@ mod imp { } })); - obj.update_selection_ui(); + obj.update_selection_toggle_sensitivity(); + obj.update_selection_toggle(); obj.update_info_label(); obj.update_desktop_audio_pipeline(); obj.update_microphone_pipeline(); @@ -438,7 +440,14 @@ impl Win { } } - fn update_selection_ui(&self) { + fn update_selection_toggle_sensitivity(&self) { + let imp = self.imp(); + + imp.selection_toggle + .set_sensitive(imp.previous_selection.get().is_some()); + } + + fn update_selection_toggle(&self) { let imp = self.imp(); imp.selection_toggle From aa3a81fae62762ab4ad8b828b3c55e1ce2a28a22 Mon Sep 17 00:00:00 2001 From: Dave Patrick Caberto Date: Sat, 25 Nov 2023 20:46:06 +0800 Subject: [PATCH 10/77] misc: downgrade viewport logs to trace --- src/area_selector/view_port.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/area_selector/view_port.rs b/src/area_selector/view_port.rs index 7ea4f5fc..c6ed14e6 100644 --- a/src/area_selector/view_port.rs +++ b/src/area_selector/view_port.rs @@ -396,7 +396,7 @@ impl ViewPort { } fn on_drag_begin(&self, _gesture: >k::GestureDrag, x: f64, y: f64) { - tracing::debug!("Drag begin at ({}, {})", x, y); + tracing::trace!("Drag begin at ({}, {})", x, y); let imp = self.imp(); let cursor_type = self.compute_cursor_type(x as f32, y as f32); @@ -626,7 +626,7 @@ impl ViewPort { } fn on_drag_end(&self, _gesture: >k::GestureDrag, dx: f64, dy: f64) { - tracing::debug!("Drag end offset ({}, {})", dx, dy); + tracing::trace!("Drag end offset ({}, {})", dx, dy); let imp = self.imp(); imp.drag_start.set(None); From 14ee5a02905f245ca9be8662cdad08b7e71af5c6 Mon Sep 17 00:00:00 2001 From: Dave Patrick Caberto Date: Sat, 25 Nov 2023 21:00:34 +0800 Subject: [PATCH 11/77] fix: only compute stream size when missing --- src/win.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/win.rs b/src/win.rs index ab116cce..4eb62b22 100644 --- a/src/win.rs +++ b/src/win.rs @@ -370,6 +370,10 @@ impl Win { match message.view() { gst::MessageView::AsyncDone(_) => { + if imp.stream_size.get().is_some() { + return glib::ControlFlow::Continue; + } + let videoconvert = imp .session .borrow() From 66d0fea4e8b07d6b118d8eb8f5956a39acd8962e Mon Sep 17 00:00:00 2001 From: Dave Patrick Caberto Date: Sat, 25 Nov 2023 21:14:00 +0800 Subject: [PATCH 12/77] misc: increase preview FPS --- src/win.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/win.rs b/src/win.rs index 4eb62b22..c0e14232 100644 --- a/src/win.rs +++ b/src/win.rs @@ -18,6 +18,8 @@ use crate::{ utils, }; +const PREVIEW_FPS: u32 = 60; + mod imp { use std::cell::{Cell, RefCell}; @@ -204,7 +206,7 @@ impl Win { settings.set_screencast_restore_token(&restore_token.unwrap_or_default()); let pipeline = gst::Pipeline::new(); - let videosrc_bin = pipeline::pipewiresrc_bin(fd, &streams, 30, None)?; + let videosrc_bin = pipeline::pipewiresrc_bin(fd, &streams, PREVIEW_FPS, None)?; let audioconvert = gst::ElementFactory::make("videoconvert") .name("sink-videoconvert") .build()?; From 2e943d228c0af533dbe8f0b3d9f25b555d3603f6 Mon Sep 17 00:00:00 2001 From: Dave Patrick Caberto Date: Sat, 25 Nov 2023 21:29:07 +0800 Subject: [PATCH 13/77] refactor: use correct var name --- src/win.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/win.rs b/src/win.rs index c0e14232..f154cbfa 100644 --- a/src/win.rs +++ b/src/win.rs @@ -207,12 +207,12 @@ impl Win { let pipeline = gst::Pipeline::new(); let videosrc_bin = pipeline::pipewiresrc_bin(fd, &streams, PREVIEW_FPS, None)?; - let audioconvert = gst::ElementFactory::make("videoconvert") + let videoconvert = gst::ElementFactory::make("videoconvert") .name("sink-videoconvert") .build()?; let sink = gst::ElementFactory::make("gtk4paintablesink").build()?; - pipeline.add_many([videosrc_bin.upcast_ref(), &audioconvert, &sink])?; - gst::Element::link_many([videosrc_bin.upcast_ref(), &audioconvert, &sink])?; + pipeline.add_many([videosrc_bin.upcast_ref(), &videoconvert, &sink])?; + gst::Element::link_many([videosrc_bin.upcast_ref(), &videoconvert, &sink])?; let bus_watch_guard = pipeline.bus().unwrap().add_watch_local( clone!(@weak self as obj => @default-panic, move |_, message| { From 82d434e68b6174508593c069dbbeb397d54a5171 Mon Sep 17 00:00:00 2001 From: Dave Patrick Caberto Date: Sat, 25 Nov 2023 21:31:34 +0800 Subject: [PATCH 14/77] misc: enable sync on fakesink Also use builder properties setting --- src/win.rs | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/src/win.rs b/src/win.rs index f154cbfa..8c29ea95 100644 --- a/src/win.rs +++ b/src/win.rs @@ -262,16 +262,17 @@ impl Win { .await .context("No desktop audio source found")?; - let pulsesrc = gst::ElementFactory::make("pulsesrc").build()?; + let pulsesrc = gst::ElementFactory::make("pulsesrc") + .property("device", device_name) + .build()?; let audioconvert = gst::ElementFactory::make("audioconvert").build()?; let level = gst::ElementFactory::make("level") .property("interval", gst::ClockTime::from_mseconds(80)) .property("peak-ttl", gst::ClockTime::from_mseconds(80)) .build()?; - let fakesink = gst::ElementFactory::make("fakesink").build()?; - - pulsesrc.set_property("device", device_name); - fakesink.set_property("sync", false); + let fakesink = gst::ElementFactory::make("fakesink") + .property("sync", true) + .build()?; let pipeline = gst::Pipeline::new(); pipeline.add_many([&pulsesrc, &audioconvert, &level, &fakesink])?; @@ -306,15 +307,17 @@ impl Win { .await .context("No microphone source found")?; - let pulsesrc = gst::ElementFactory::make("pulsesrc").build()?; + let pulsesrc = gst::ElementFactory::make("pulsesrc") + .property("device", device_name) + .build()?; let audioconvert = gst::ElementFactory::make("audioconvert").build()?; let level = gst::ElementFactory::make("level") .property("interval", gst::ClockTime::from_mseconds(80)) .property("peak-ttl", gst::ClockTime::from_mseconds(80)) .build()?; - let fakesink = gst::ElementFactory::make("fakesink").build()?; - - pulsesrc.set_property("device", device_name); + let fakesink = gst::ElementFactory::make("fakesink") + .property("sync", true) + .build()?; let pipeline = gst::Pipeline::new(); pipeline.add_many([&pulsesrc, &audioconvert, &level, &fakesink])?; From e9f46a99c5c89ff41a8e6d2a353b73082d395478 Mon Sep 17 00:00:00 2001 From: Dave Patrick Caberto Date: Sat, 25 Nov 2023 21:53:38 +0800 Subject: [PATCH 15/77] misc: fallback selection to middle of view port --- src/area_selector/view_port.rs | 9 +++++++++ src/win.rs | 25 ++++++++++++++++++++----- 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/src/area_selector/view_port.rs b/src/area_selector/view_port.rs index c6ed14e6..c9a473c5 100644 --- a/src/area_selector/view_port.rs +++ b/src/area_selector/view_port.rs @@ -77,6 +77,15 @@ impl fmt::Debug for Selection { } impl Selection { + pub fn new(start_x: f32, start_y: f32, end_x: f32, end_y: f32) -> Self { + Self { + start_x, + start_y, + end_x, + end_y, + } + } + pub fn left_x(&self) -> f32 { self.start_x.min(self.end_x) } diff --git a/src/win.rs b/src/win.rs index 8c29ea95..6fddbf2b 100644 --- a/src/win.rs +++ b/src/win.rs @@ -87,18 +87,33 @@ mod imp { self.selection_toggle .connect_active_notify(clone!(@weak obj => move |toggle| { + let imp = obj.imp(); if toggle.is_active() { - let prev_selection = obj.imp().previous_selection.get(); - obj.imp().view_port.set_selection(prev_selection); + let selection = obj.imp().previous_selection.get().unwrap_or_else(|| { + let mid_x = imp.view_port.width() as f32 / 2.0; + let mid_y = imp.view_port.height() as f32 / 2.0; + let offset = 20.0 * imp.view_port.scale_factor() as f32; + Selection::new( + mid_x - offset, + mid_y - offset, + mid_x + offset, + mid_y + offset, + ) + }); + imp.view_port.set_selection(Some(selection)); } else { - obj.imp().view_port.set_selection(None); + imp.view_port.set_selection(None); } })); + self.view_port + .connect_paintable_notify(clone!(@weak obj => move |_| { + obj.update_selection_toggle_sensitivity(); + obj.update_info_label(); + })); self.view_port .connect_selection_notify(clone!(@weak obj => move |view_port| { if let Some(selection) = view_port.selection() { obj.imp().previous_selection.replace(Some(selection)); - obj.update_selection_toggle_sensitivity(); } obj.update_selection_toggle(); obj.update_info_label(); @@ -453,7 +468,7 @@ impl Win { let imp = self.imp(); imp.selection_toggle - .set_sensitive(imp.previous_selection.get().is_some()); + .set_sensitive(imp.view_port.paintable().is_some()); } fn update_selection_toggle(&self) { From 8cfa6921cfe65ff864dcc481864657f4d47a0b06 Mon Sep 17 00:00:00 2001 From: Dave Patrick Caberto Date: Sun, 26 Nov 2023 09:06:53 +0800 Subject: [PATCH 16/77] misc: use new pipeline --- src/utils.rs | 3 +- src/win.rs | 78 ++++++++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 77 insertions(+), 4 deletions(-) diff --git a/src/utils.rs b/src/utils.rs index ce6ac63b..0657f8c0 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -8,6 +8,7 @@ use std::{env, path::Path}; use crate::Application; +const MIN_THREAD_COUNT: u32 = 1; const MAX_THREAD_COUNT: u32 = 64; /// Get the global instance of `Application`. @@ -31,7 +32,7 @@ pub fn is_flatpak() -> bool { /// Ideal thread count to use for `GStreamer` processing. pub fn ideal_thread_count() -> u32 { - glib::num_processors().min(MAX_THREAD_COUNT) + glib::num_processors().clamp(MIN_THREAD_COUNT, MAX_THREAD_COUNT) } pub fn is_experimental_mode() -> bool { diff --git a/src/win.rs b/src/win.rs index 6fddbf2b..96d09f34 100644 --- a/src/win.rs +++ b/src/win.rs @@ -1,3 +1,5 @@ +use std::os::fd::RawFd; + use adw::{prelude::*, subclass::prelude::*}; use anyhow::{Context, Result}; use gettextrs::gettext; @@ -12,8 +14,7 @@ use crate::{ area_selector::{Selection, ViewPort}, audio_device::{self, Class as AudioDeviceClass}, config::PROFILE, - pipeline, - screencast_session::{CursorMode, PersistMode, ScreencastSession, SourceType}, + screencast_session::{CursorMode, PersistMode, ScreencastSession, SourceType, Stream}, toggle_button::ToggleButton, utils, }; @@ -221,7 +222,7 @@ impl Win { settings.set_screencast_restore_token(&restore_token.unwrap_or_default()); let pipeline = gst::Pipeline::new(); - let videosrc_bin = pipeline::pipewiresrc_bin(fd, &streams, PREVIEW_FPS, None)?; + let videosrc_bin = pipewiresrc_bin(fd, &streams, PREVIEW_FPS)?; let videoconvert = gst::ElementFactory::make("videoconvert") .name("sink-videoconvert") .build()?; @@ -587,3 +588,74 @@ fn handle_audio_bus_message(message: &gst::Message, callback: impl Fn(f64)) -> g } } } + +fn pipewiresrc_with_default(fd: RawFd, path: &str) -> Result { + let src = gst::ElementFactory::make("pipewiresrc") + .property("fd", fd) + .property("path", path) + .property("do-timestamp", true) + .property("keepalive-time", 1000) + .property("resend-last", true) + .build()?; + Ok(src) +} + +fn videoconvert_with_default() -> Result { + let conv = gst::ElementFactory::make("videoconvert") + .property("chroma-mode", gst_video::VideoChromaMode::None) + .property("dither", gst_video::VideoDitherMethod::None) + .property("matrix-mode", gst_video::VideoMatrixMode::OutputOnly) + .property("n-threads", utils::ideal_thread_count()) + .build()?; + Ok(conv) +} + +/// Creates a bin with a src pad for multiple pipewire streams. +/// +/// pipewiresrc1 -> videorate -> | +/// | +/// pipewiresrc2 -> videorate -> | -> compositor -> videoconvert +/// | +/// pipewiresrcn -> videorate -> | +fn pipewiresrc_bin(fd: RawFd, streams: &[Stream], framerate: u32) -> Result { + let bin = gst::Bin::new(); + + let compositor = gst::ElementFactory::make("compositor").build()?; + let videoconvert = videoconvert_with_default()?; + + bin.add_many([&compositor, &videoconvert])?; + compositor.link(&videoconvert)?; + + let videorate_caps = gst::Caps::builder("video/x-raw") + .field("framerate", gst::Fraction::new(framerate as i32, 1)) + .build(); + + let mut last_pos = 0; + for stream in streams { + let pipewiresrc = pipewiresrc_with_default(fd, &stream.node_id().to_string())?; + let videorate = gst::ElementFactory::make("videorate").build()?; + let videorate_capsfilter = gst::ElementFactory::make("capsfilter") + .property("caps", &videorate_caps) + .build()?; + + bin.add_many([&pipewiresrc, &videorate, &videorate_capsfilter])?; + gst::Element::link_many([&pipewiresrc, &videorate, &videorate_capsfilter])?; + + let compositor_sink_pad = compositor + .request_pad_simple("sink_%u") + .context("Failed to request sink_%u pad from compositor")?; + compositor_sink_pad.set_property("xpos", last_pos); + videorate_capsfilter + .static_pad("src") + .unwrap() + .link(&compositor_sink_pad)?; + + let (stream_width, _) = stream.size().context("stream is missing size")?; + last_pos += stream_width; + } + + let videoconvert_src_pad = videoconvert.static_pad("src").unwrap(); + bin.add_pad(&gst::GhostPad::with_target(&videoconvert_src_pad)?)?; + + Ok(bin) +} From 0ab9cfeaded54b9fd384c2f019de6eba4017b791 Mon Sep 17 00:00:00 2001 From: Dave Patrick Caberto Date: Sun, 26 Nov 2023 09:27:22 +0800 Subject: [PATCH 17/77] fix: dispose previous session --- src/win.rs | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/src/win.rs b/src/win.rs index 96d09f34..3bc87695 100644 --- a/src/win.rs +++ b/src/win.rs @@ -136,9 +136,7 @@ mod imp { } fn dispose(&self) { - if let Some((_, pipeline, _)) = self.session.take() { - let _ = pipeline.set_state(gst::State::Null); - } + self.obj().dispose_session(); if let Some((pipeline, _)) = self.desktop_audio_pipeline.take() { let _ = pipeline.set_state(gst::State::Null); @@ -183,6 +181,18 @@ impl Win { .build() } + fn dispose_session(&self) { + if let Some((session, pipeline, _)) = self.imp().session.take() { + let _ = pipeline.set_state(gst::State::Null); + + glib::spawn_future_local(async move { + if let Err(err) = session.close().await { + tracing::error!("Failed to end ScreencastSession: {:?}", err); + } + }); + } + } + async fn replace_session(&self, restore_token: Option<&str>) -> Result<()> { let imp = self.imp(); @@ -239,9 +249,7 @@ impl Win { imp.stream_size.set(None); self.update_info_label(); - if let Some((_, pipeline, _)) = imp.session.take() { - let _ = pipeline.set_state(gst::State::Null); - } + self.dispose_session(); imp.session .replace(Some((session, pipeline, bus_watch_guard))); From 38a5d397104f454454e1a9c2d4cc3039fabf26c7 Mon Sep 17 00:00:00 2001 From: Dave Patrick Caberto Date: Sun, 26 Nov 2023 09:41:53 +0800 Subject: [PATCH 18/77] refactor: use constants for color --- src/main.rs | 1 + src/view_port.rs | 781 +++++++++++++++++++++++++++++++++++++++++++++++ src/win.rs | 2 +- 3 files changed, 783 insertions(+), 1 deletion(-) create mode 100644 src/view_port.rs diff --git a/src/main.rs b/src/main.rs index 2adf8106..95942a44 100644 --- a/src/main.rs +++ b/src/main.rs @@ -41,6 +41,7 @@ mod settings; mod timer; mod toggle_button; mod utils; +mod view_port; mod win; mod window; diff --git a/src/view_port.rs b/src/view_port.rs new file mode 100644 index 00000000..f7f8a428 --- /dev/null +++ b/src/view_port.rs @@ -0,0 +1,781 @@ +// Based on gnome-shell's screenshot ui (GPLv2). +// Source: https://gitlab.gnome.org/GNOME/gnome-shell/-/blob/a3c84ca7463ed92b5be6f013a12bce927223f7c5/js/ui/screenshot.js + +use gtk::{ + gdk, + glib::{self, clone}, + graphene::{Point, Rect}, + gsk::RoundedRect, + prelude::*, + subclass::prelude::*, +}; + +use std::{ + cell::{Cell, RefCell}, + fmt, +}; + +const DEFAULT_SIZE: f64 = 100.0; + +const SHADE_COLOR: gdk::RGBA = gdk::RGBA::new(0.0, 0.0, 0.0, 0.5); + +const SELECTION_COLOR: gdk::RGBA = gdk::RGBA::WHITE; +const SELECTION_HANDLE_SHADOW_COLOR: gdk::RGBA = gdk::RGBA::new(0.0, 0.0, 0.0, 0.2); +const SELECTION_HANDLE_RADIUS: f32 = 12.0; +const SELECTION_LINE_WIDTH: f32 = 2.0; + +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +enum CursorType { + #[default] + Default, + Crosshair, + Move, + NorthResize, + SouthResize, + EastResize, + WestResize, + NorthEastResize, + NorthWestResize, + SouthEastResize, + SouthWestResize, +} + +impl CursorType { + fn name(self) -> &'static str { + match self { + Self::Default => "default", + Self::Crosshair => "crosshair", + Self::Move => "move", + Self::NorthResize => "n-resize", + Self::SouthResize => "s-resize", + Self::EastResize => "e-resize", + Self::WestResize => "w-resize", + Self::NorthEastResize => "ne-resize", + Self::NorthWestResize => "nw-resize", + Self::SouthEastResize => "se-resize", + Self::SouthWestResize => "sw-resize", + } + } +} + +#[derive(Default, Clone, Copy, glib::Boxed)] +#[boxed_type(name = "KoohaSelection", nullable)] +pub struct Selection { + start_x: f32, + start_y: f32, + end_x: f32, + end_y: f32, +} + +impl fmt::Debug for Selection { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let rect = self.rect(); + f.debug_struct("Selection") + .field("x", &rect.x()) + .field("y", &rect.y()) + .field("width", &rect.width()) + .field("height", &rect.height()) + .finish() + } +} + +impl Selection { + pub fn new(start_x: f32, start_y: f32, end_x: f32, end_y: f32) -> Self { + Self { + start_x, + start_y, + end_x, + end_y, + } + } + + pub fn left_x(&self) -> f32 { + self.start_x.min(self.end_x) + } + + pub fn right_x(&self) -> f32 { + self.start_x.max(self.end_x) + } + + pub fn top_y(&self) -> f32 { + self.start_y.min(self.end_y) + } + + pub fn bottom_y(&self) -> f32 { + self.start_y.max(self.end_y) + } + + pub fn rect(&self) -> Rect { + Rect::new( + self.left_x(), + self.top_y(), + (self.start_x - self.end_x).abs(), + (self.start_y - self.end_y).abs(), + ) + } +} + +mod imp { + use super::*; + + #[derive(Debug, Default, glib::Properties)] + #[properties(wrapper_type = super::ViewPort)] + pub struct ViewPort { + #[property(get, set = Self::set_paintable, explicit_notify, nullable)] + pub(super) paintable: RefCell>, + #[property(get)] + pub(super) selection: Cell>, + + pub(super) paintable_rect: Cell>, + pub(super) selection_handles: Cell>, // [top-left, top-right, bottom-right, bottom-left] + + pub(super) drag_start: Cell>, + pub(super) drag_cursor: Cell, + + pub(super) pointer_position: Cell>, + + pub(super) handler_ids: RefCell>, + } + + #[glib::object_subclass] + impl ObjectSubclass for ViewPort { + const NAME: &'static str = "KoohaViewPort"; + type Type = super::ViewPort; + type ParentType = gtk::Widget; + } + + #[glib::derived_properties] + impl ObjectImpl for ViewPort { + fn constructed(&self) { + self.parent_constructed(); + + let obj = self.obj(); + + let motion_controller = gtk::EventControllerMotion::new(); + motion_controller.connect_enter(clone!(@weak obj => move |controller, x, y| { + obj.on_enter(controller, x, y); + })); + motion_controller.connect_motion(clone!(@weak obj => move |controller, x, y| { + obj.on_motion(controller, x, y); + })); + motion_controller.connect_leave(clone!(@weak obj => move |controller| { + obj.on_leave(controller); + })); + obj.add_controller(motion_controller); + + let gesture_drag = gtk::GestureDrag::builder().exclusive(true).build(); + gesture_drag.connect_drag_begin(clone!(@weak obj => move |controller, x, y| { + obj.on_drag_begin(controller, x, y); + })); + gesture_drag.connect_drag_update(clone!(@weak obj => move |controller, dx, dy| { + obj.on_drag_update(controller, dx, dy); + })); + gesture_drag.connect_drag_end(clone!(@weak obj => move |controller, dx, dy| { + obj.on_drag_end(controller, dx, dy); + })); + obj.add_controller(gesture_drag); + } + } + + impl WidgetImpl for ViewPort { + fn request_mode(&self) -> gtk::SizeRequestMode { + gtk::SizeRequestMode::HeightForWidth + } + + fn measure(&self, orientation: gtk::Orientation, for_size: i32) -> (i32, i32, i32, i32) { + if for_size == 0 { + return (0, 0, -1, -1); + } + + let Some(paintable) = self.obj().paintable() else { + return (0, 0, -1, -1); + }; + + if orientation == gtk::Orientation::Horizontal { + let (natural_width, _natural_height) = paintable.compute_concrete_size( + 0.0, + if for_size < 0 { 0.0 } else { for_size as f64 }, + DEFAULT_SIZE, + DEFAULT_SIZE, + ); + (0, natural_width.ceil() as i32, -1, -1) + } else { + let (_natural_width, natural_height) = paintable.compute_concrete_size( + if for_size < 0 { 0.0 } else { for_size as f64 }, + 0.0, + DEFAULT_SIZE, + DEFAULT_SIZE, + ); + (0, natural_height.ceil() as i32, -1, -1) + } + } + + fn snapshot(&self, snapshot: >k::Snapshot) { + let obj = self.obj(); + + if let Some(paintable) = obj.paintable() { + let widget_width = obj.width() as f64; + let widget_height = obj.height() as f64; + let widget_ratio = widget_width / widget_height; + + let paintable_width = paintable.intrinsic_width() as f64; + let paintable_height = paintable.intrinsic_height() as f64; + let paintable_ratio = paintable.intrinsic_aspect_ratio(); + + let (width, height) = + if widget_width >= paintable_width && widget_height >= paintable_height { + (paintable_width, paintable_height) + } else if paintable_ratio > widget_ratio { + (widget_width, widget_width / paintable_ratio) + } else { + (widget_height * paintable_ratio, widget_height) + }; + let x = (widget_width - width.ceil()) / 2.0; + let y = (widget_height - height.ceil()).floor() / 2.0; + + obj.imp().paintable_rect.set(Some(Rect::new( + x as f32, + y as f32, + width as f32, + height as f32, + ))); + + snapshot.save(); + snapshot.translate(&Point::new(x as f32, y as f32)); + paintable.snapshot(snapshot, width, height); + snapshot.restore(); + } + + if let Some(selection) = obj.selection() { + let selection_rect = selection.rect(); + + if let Some(paintable_rect) = obj.paintable_rect() { + snapshot.append_color( + &SHADE_COLOR, + &Rect::new( + paintable_rect.x(), + paintable_rect.y(), + selection.left_x() - paintable_rect.x(), + paintable_rect.height(), + ), + ); + snapshot.append_color( + &SHADE_COLOR, + &Rect::new( + selection.right_x(), + paintable_rect.y(), + paintable_rect.width() + paintable_rect.x() - selection.right_x(), + paintable_rect.height(), + ), + ); + snapshot.append_color( + &SHADE_COLOR, + &Rect::new( + selection.left_x(), + paintable_rect.y(), + selection_rect.width(), + selection.top_y() - paintable_rect.y(), + ), + ); + snapshot.append_color( + &SHADE_COLOR, + &Rect::new( + selection.left_x(), + selection.bottom_y(), + selection_rect.width(), + paintable_rect.height() + paintable_rect.y() - selection.bottom_y(), + ) + .normalize_r(), + ); + } + + snapshot.append_border( + &RoundedRect::from_rect( + Rect::new( + selection_rect.x(), + selection_rect.y(), + selection_rect.width().max(1.0), + selection_rect.height().max(1.0), + ), + 0.0, + ), + &[SELECTION_LINE_WIDTH; 4], + &[SELECTION_COLOR; 4], + ); + + for handle in self.selection_handles.get().unwrap() { + let bounds = RoundedRect::from_rect(handle, SELECTION_HANDLE_RADIUS); + snapshot.append_outset_shadow( + &bounds, + &SELECTION_HANDLE_SHADOW_COLOR, + 0.0, + 1.0, + 2.0, + 3.0, + ); + snapshot.push_rounded_clip(&bounds); + snapshot.append_color(&SELECTION_COLOR, &handle); + snapshot.pop(); + } + } + } + } + + impl ViewPort { + fn set_paintable(&self, paintable: Option) { + let obj = self.obj(); + + if paintable == obj.paintable() { + return; + } + + let _freeze_guard = obj.freeze_notify(); + + let mut handler_ids = self.handler_ids.borrow_mut(); + + if let Some(previous_paintable) = self.paintable.replace(paintable.clone()) { + for handler_id in handler_ids.drain(..) { + previous_paintable.disconnect(handler_id); + } + } + + if let Some(paintable) = paintable { + handler_ids.push(paintable.connect_invalidate_contents( + clone!(@weak obj => move |_| { + obj.queue_draw(); + }), + )); + handler_ids.push( + paintable.connect_invalidate_size(clone!(@weak obj => move |_| { + obj.queue_resize(); + })), + ); + } + + obj.queue_resize(); + obj.notify_paintable(); + } + } +} + +glib::wrapper! { + pub struct ViewPort(ObjectSubclass) + @extends gtk::Widget; +} + +impl ViewPort { + pub fn new() -> Self { + glib::Object::builder().build() + } + + pub fn paintable_rect(&self) -> Option { + self.imp().paintable_rect.get() + } + + pub fn set_selection(&self, selection: Option) { + self.imp().selection.set(selection); + self.update_selection_handles(); + self.queue_draw(); + self.notify_selection(); + } + + fn on_enter(&self, _controller: >k::EventControllerMotion, x: f64, y: f64) { + let imp = self.imp(); + + imp.pointer_position + .set(Some(Point::new(x as f32, y as f32))); + } + + fn on_motion(&self, _controller: >k::EventControllerMotion, x: f64, y: f64) { + let imp = self.imp(); + + imp.pointer_position + .set(Some(Point::new(x as f32, y as f32))); + + if imp.drag_start.get().is_none() { + let cursor_type = self.compute_cursor_type(x as f32, y as f32); + self.set_cursor(cursor_type); + } + } + + fn on_leave(&self, _controller: >k::EventControllerMotion) { + let imp = self.imp(); + + imp.pointer_position.set(None); + + self.set_cursor(CursorType::Default); + } + + fn on_drag_begin(&self, _gesture: >k::GestureDrag, x: f64, y: f64) { + tracing::trace!("Drag begin at ({}, {})", x, y); + + let imp = self.imp(); + let cursor_type = self.compute_cursor_type(x as f32, y as f32); + + if cursor_type == CursorType::Crosshair { + imp.drag_cursor.set(CursorType::Crosshair); + self.set_cursor(CursorType::Crosshair); + + let paintable_rect = self.paintable_rect().unwrap(); + let x = (x as f32).clamp( + paintable_rect.x(), + paintable_rect.x() + paintable_rect.width(), + ); + let y = (y as f32).clamp( + paintable_rect.y(), + paintable_rect.y() + paintable_rect.height(), + ); + self.set_selection(Some(Selection { + start_x: x, + start_y: y, + end_x: x, + end_y: y, + })); + } else { + imp.drag_cursor.set(cursor_type); + imp.drag_start.set(Some(Point::new(x as f32, y as f32))); + + let selection = self.selection().unwrap(); + let mut new_selection = self.selection().unwrap(); + + if cursor_type == CursorType::Move { + new_selection.start_x = selection.left_x(); + new_selection.start_y = selection.top_y(); + new_selection.end_x = selection.right_x(); + new_selection.end_y = selection.bottom_y(); + } + if matches!( + cursor_type, + CursorType::NorthWestResize | CursorType::WestResize | CursorType::SouthWestResize + ) { + new_selection.start_x = selection.right_x(); + new_selection.end_x = selection.left_x(); + } + if matches!( + cursor_type, + CursorType::NorthEastResize | CursorType::EastResize | CursorType::SouthEastResize + ) { + new_selection.start_x = selection.left_x(); + new_selection.end_x = selection.right_x(); + } + if matches!( + cursor_type, + CursorType::NorthWestResize | CursorType::NorthResize | CursorType::NorthEastResize + ) { + new_selection.start_y = selection.bottom_y(); + new_selection.end_y = selection.top_y(); + } + if matches!( + cursor_type, + CursorType::SouthWestResize | CursorType::SouthResize | CursorType::SouthEastResize + ) { + new_selection.start_y = selection.top_y(); + new_selection.end_y = selection.bottom_y(); + } + + self.set_selection(Some(new_selection)); + } + } + + fn on_drag_update(&self, _gesture: >k::GestureDrag, _: f64, _: f64) { + let imp = self.imp(); + + let pointer_position = imp.pointer_position.get().unwrap(); + + let drag_cursor = imp.drag_cursor.get(); + + if drag_cursor == CursorType::Crosshair { + let Selection { + start_x, start_y, .. + } = self.selection().unwrap(); + let paintable_rect = self.paintable_rect().unwrap(); + self.set_selection(Some(Selection { + start_x, + start_y, + end_x: pointer_position.x().clamp( + paintable_rect.x(), + paintable_rect.width() + paintable_rect.x(), + ), + end_y: pointer_position.y().clamp( + paintable_rect.y(), + paintable_rect.height() + paintable_rect.y(), + ), + })); + } else { + let drag_start = imp.drag_start.get().unwrap(); + let mut dx = pointer_position.x() - drag_start.x(); + let mut dy = pointer_position.y() - drag_start.y(); + + if drag_cursor == CursorType::Move { + let Selection { + start_x, + start_y, + end_x, + end_y, + } = self.selection().unwrap(); + let mut new_start_x = start_x + dx; + let mut new_start_y = start_y + dy; + let mut new_end_x = end_x + dx; + let mut new_end_y = end_y + dy; + + let mut overshoot_x = 0.0; + let mut overshoot_y = 0.0; + + let paintable_rect = self.paintable_rect().unwrap(); + let selection_rect = self.selection().unwrap().rect(); + + // Keep the size intact if we bumped into the stage edge. + if new_start_x < paintable_rect.x() { + overshoot_x = paintable_rect.x() - new_start_x; + new_start_x = paintable_rect.x(); + new_end_x = new_start_x + selection_rect.width(); + } else if new_end_x > paintable_rect.width() + paintable_rect.x() { + overshoot_x = paintable_rect.width() + paintable_rect.x() - new_end_x; + new_end_x = paintable_rect.width() + paintable_rect.x(); + new_start_x = new_end_x - selection_rect.width(); + } + if new_start_y < paintable_rect.y() { + overshoot_y = paintable_rect.y() - new_start_y; + new_start_y = paintable_rect.y(); + new_end_y = new_start_y + selection_rect.height(); + } else if new_end_y > paintable_rect.height() + paintable_rect.y() { + overshoot_y = paintable_rect.height() + paintable_rect.y() - new_end_y; + new_end_y = paintable_rect.height() + paintable_rect.y(); + new_start_y = new_end_y - selection_rect.height(); + } + + dx += overshoot_x; + dy += overshoot_y; + + self.set_selection(Some(Selection { + start_x: new_start_x, + start_y: new_start_y, + end_x: new_end_x, + end_y: new_end_y, + })); + } else { + if matches!(drag_cursor, CursorType::WestResize | CursorType::EastResize) { + dy = 0.0; + } + if matches!( + drag_cursor, + CursorType::NorthResize | CursorType::SouthResize + ) { + dx = 0.0; + } + + let paintable_rect = self.paintable_rect().unwrap(); + let mut new_selection = self.selection().unwrap(); + + new_selection.end_x += dx; + if new_selection.end_x >= paintable_rect.width() + paintable_rect.x() { + dx -= new_selection.end_x - (paintable_rect.width() + paintable_rect.x()); + new_selection.end_x = paintable_rect.width() + paintable_rect.x(); + } else if new_selection.end_x < paintable_rect.x() { + dx -= new_selection.end_x - paintable_rect.x(); + new_selection.end_x = paintable_rect.x(); + } + + new_selection.end_y += dy; + if new_selection.end_y >= paintable_rect.height() + paintable_rect.y() { + dy -= new_selection.end_y - (paintable_rect.height() + paintable_rect.y()); + new_selection.end_y = paintable_rect.height() + paintable_rect.y(); + } else if new_selection.end_y < paintable_rect.y() { + dy -= new_selection.end_y - paintable_rect.y(); + new_selection.end_y = paintable_rect.y(); + } + + self.set_selection(Some(new_selection)); + let selection = new_selection; + + // If we drag the handle past a selection side, update which + // handles are which. + if selection.end_x > selection.start_x { + if drag_cursor == CursorType::NorthWestResize { + imp.drag_cursor.set(CursorType::NorthEastResize); + } else if drag_cursor == CursorType::SouthWestResize { + imp.drag_cursor.set(CursorType::SouthEastResize); + } else if drag_cursor == CursorType::WestResize { + imp.drag_cursor.set(CursorType::EastResize); + } + } else { + // Disable clippy error + if drag_cursor == CursorType::NorthEastResize { + imp.drag_cursor.set(CursorType::NorthWestResize); + } else if drag_cursor == CursorType::SouthEastResize { + imp.drag_cursor.set(CursorType::SouthWestResize); + } else if drag_cursor == CursorType::EastResize { + imp.drag_cursor.set(CursorType::WestResize); + } + } + + if selection.end_y > selection.start_y { + if drag_cursor == CursorType::NorthWestResize { + imp.drag_cursor.set(CursorType::SouthWestResize); + } else if drag_cursor == CursorType::NorthEastResize { + imp.drag_cursor.set(CursorType::SouthEastResize); + } else if drag_cursor == CursorType::NorthResize { + imp.drag_cursor.set(CursorType::SouthResize); + } + } else { + // Disable clippy error + if drag_cursor == CursorType::SouthWestResize { + imp.drag_cursor.set(CursorType::NorthWestResize); + } else if drag_cursor == CursorType::SouthEastResize { + imp.drag_cursor.set(CursorType::NorthEastResize); + } else if drag_cursor == CursorType::SouthResize { + imp.drag_cursor.set(CursorType::NorthResize); + } + } + + self.set_cursor(imp.drag_cursor.get()); + } + + imp.drag_start + .set(Some(Point::new(drag_start.x() + dx, drag_start.y() + dy))); + } + } + + fn on_drag_end(&self, _gesture: >k::GestureDrag, dx: f64, dy: f64) { + tracing::trace!("Drag end offset ({}, {})", dx, dy); + + let imp = self.imp(); + imp.drag_start.set(None); + + // The user clicked without dragging. Make up a larger selection + // to reduce confusion. + if let Some(mut selection) = self.selection() { + if imp.drag_cursor.get() == CursorType::Crosshair + && selection.end_x == selection.start_x + && selection.end_y == selection.start_y + { + let offset = 20.0 * self.scale_factor() as f32; + selection.start_x -= offset; + selection.start_y -= offset; + selection.end_x += offset; + selection.end_y += offset; + + let paintable_rect = self.paintable_rect().unwrap(); + let selection_rect = selection.rect(); + + // Keep the coordinates inside the stage. + if selection.start_x < paintable_rect.x() { + selection.start_x = paintable_rect.x(); + selection.end_x = selection.start_x + selection_rect.width(); + } else if selection.end_x > paintable_rect.width() + paintable_rect.x() { + selection.end_x = paintable_rect.width() + paintable_rect.x(); + selection.start_x = selection.end_x - selection_rect.width(); + } + if selection.start_y < paintable_rect.y() { + selection.start_y = paintable_rect.y(); + selection.end_y = selection.start_y + selection_rect.height(); + } else if selection.end_y > paintable_rect.height() + paintable_rect.y() { + selection.end_y = paintable_rect.height() + paintable_rect.y(); + selection.start_y = selection.end_y - selection_rect.height(); + } + + self.set_selection(Some(selection)); + } + } + + if let Some(pointer_position) = imp.pointer_position.get() { + let cursor_type = self.compute_cursor_type(pointer_position.x(), pointer_position.y()); + self.set_cursor(cursor_type); + } + } + + fn set_cursor(&self, cursor_type: CursorType) { + self.set_cursor_from_name(Some(cursor_type.name())); + } + + fn compute_cursor_type(&self, x: f32, y: f32) -> CursorType { + let imp = self.imp(); + + let point = Point::new(x, y); + + let Some(selection) = self.selection() else { + return CursorType::Crosshair; + }; + + let [top_left_handle, top_right_handle, bottom_right_handle, bottom_left_handle] = + imp.selection_handles.get().unwrap(); + + if top_left_handle.contains_point(&point) { + CursorType::NorthWestResize + } else if top_right_handle.contains_point(&point) { + CursorType::NorthEastResize + } else if bottom_right_handle.contains_point(&point) { + CursorType::SouthEastResize + } else if bottom_left_handle.contains_point(&point) { + CursorType::SouthWestResize + } else if selection.rect().contains_point(&point) { + CursorType::Move + } else if top_left_handle + .union(&top_right_handle) + .contains_point(&point) + { + CursorType::NorthResize + } else if top_right_handle + .union(&bottom_right_handle) + .contains_point(&point) + { + CursorType::EastResize + } else if bottom_right_handle + .union(&bottom_left_handle) + .contains_point(&point) + { + CursorType::SouthResize + } else if bottom_left_handle + .union(&top_left_handle) + .contains_point(&point) + { + CursorType::WestResize + } else { + CursorType::Crosshair + } + } + + fn update_selection_handles(&self) { + let imp = self.imp(); + + let Some(selection) = self.selection() else { + imp.selection_handles.set(None); + return; + }; + + let selection_handle_diameter = SELECTION_HANDLE_RADIUS * 2.0; + let top_left = Rect::new( + selection.left_x() - SELECTION_HANDLE_RADIUS, + selection.top_y() - SELECTION_HANDLE_RADIUS, + selection_handle_diameter, + selection_handle_diameter, + ); + let top_right = Rect::new( + selection.right_x() - SELECTION_HANDLE_RADIUS, + selection.top_y() - SELECTION_HANDLE_RADIUS, + selection_handle_diameter, + selection_handle_diameter, + ); + let bottom_right = Rect::new( + selection.right_x() - SELECTION_HANDLE_RADIUS, + selection.bottom_y() - SELECTION_HANDLE_RADIUS, + selection_handle_diameter, + selection_handle_diameter, + ); + let bottom_left = Rect::new( + selection.left_x() - SELECTION_HANDLE_RADIUS, + selection.bottom_y() - SELECTION_HANDLE_RADIUS, + selection_handle_diameter, + selection_handle_diameter, + ); + + imp.selection_handles + .set(Some([top_left, top_right, bottom_right, bottom_left])); + } +} + +impl Default for ViewPort { + fn default() -> Self { + Self::new() + } +} diff --git a/src/win.rs b/src/win.rs index 3bc87695..9e87efac 100644 --- a/src/win.rs +++ b/src/win.rs @@ -11,12 +11,12 @@ use gtk::{ use crate::{ application::Application, - area_selector::{Selection, ViewPort}, audio_device::{self, Class as AudioDeviceClass}, config::PROFILE, screencast_session::{CursorMode, PersistMode, ScreencastSession, SourceType, Stream}, toggle_button::ToggleButton, utils, + view_port::{Selection, ViewPort}, }; const PREVIEW_FPS: u32 = 60; From 0a7927fc3c0871c3b08347be17540bd5ae794f70 Mon Sep 17 00:00:00 2001 From: Dave Patrick Caberto Date: Sun, 26 Nov 2023 09:46:36 +0800 Subject: [PATCH 19/77] misc: drop useless normalization --- src/view_port.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/view_port.rs b/src/view_port.rs index f7f8a428..15bee099 100644 --- a/src/view_port.rs +++ b/src/view_port.rs @@ -249,6 +249,7 @@ mod imp { if let Some(selection) = obj.selection() { let selection_rect = selection.rect(); + // Shades the area outside the selection. if let Some(paintable_rect) = obj.paintable_rect() { snapshot.append_color( &SHADE_COLOR, @@ -284,8 +285,7 @@ mod imp { selection.bottom_y(), selection_rect.width(), paintable_rect.height() + paintable_rect.y() - selection.bottom_y(), - ) - .normalize_r(), + ), ); } From d8c0bb7981b9773a798357ed14471ad6e87c9fd7 Mon Sep 17 00:00:00 2001 From: Dave Patrick Caberto Date: Sun, 26 Nov 2023 09:54:59 +0800 Subject: [PATCH 20/77] refactor: simplify selection shade --- src/view_port.rs | 45 ++++++++------------------------------------- 1 file changed, 8 insertions(+), 37 deletions(-) diff --git a/src/view_port.rs b/src/view_port.rs index 15bee099..19f880f4 100644 --- a/src/view_port.rs +++ b/src/view_port.rs @@ -5,7 +5,7 @@ use gtk::{ gdk, glib::{self, clone}, graphene::{Point, Rect}, - gsk::RoundedRect, + gsk::{self, RoundedRect}, prelude::*, subclass::prelude::*, }; @@ -251,42 +251,13 @@ mod imp { // Shades the area outside the selection. if let Some(paintable_rect) = obj.paintable_rect() { - snapshot.append_color( - &SHADE_COLOR, - &Rect::new( - paintable_rect.x(), - paintable_rect.y(), - selection.left_x() - paintable_rect.x(), - paintable_rect.height(), - ), - ); - snapshot.append_color( - &SHADE_COLOR, - &Rect::new( - selection.right_x(), - paintable_rect.y(), - paintable_rect.width() + paintable_rect.x() - selection.right_x(), - paintable_rect.height(), - ), - ); - snapshot.append_color( - &SHADE_COLOR, - &Rect::new( - selection.left_x(), - paintable_rect.y(), - selection_rect.width(), - selection.top_y() - paintable_rect.y(), - ), - ); - snapshot.append_color( - &SHADE_COLOR, - &Rect::new( - selection.left_x(), - selection.bottom_y(), - selection_rect.width(), - paintable_rect.height() + paintable_rect.y() - selection.bottom_y(), - ), - ); + snapshot.push_mask(gsk::MaskMode::InvertedAlpha); + + snapshot.append_color(&gdk::RGBA::BLACK, &selection_rect); + snapshot.pop(); + + snapshot.append_color(&SHADE_COLOR, &paintable_rect); + snapshot.pop(); } snapshot.append_border( From b5971afc266f83fecbe1be860cc099dcb8ae3add Mon Sep 17 00:00:00 2001 From: Dave Patrick Caberto Date: Sun, 26 Nov 2023 10:02:04 +0800 Subject: [PATCH 21/77] refactor: use self where more applicable --- src/view_port.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/view_port.rs b/src/view_port.rs index 19f880f4..fe35e6da 100644 --- a/src/view_port.rs +++ b/src/view_port.rs @@ -233,7 +233,7 @@ mod imp { let x = (widget_width - width.ceil()) / 2.0; let y = (widget_height - height.ceil()).floor() / 2.0; - obj.imp().paintable_rect.set(Some(Rect::new( + self.paintable_rect.set(Some(Rect::new( x as f32, y as f32, width as f32, From 27172ff37f58b8cc4f992d442ffde435f1c9628d Mon Sep 17 00:00:00 2001 From: Dave Patrick Caberto Date: Sun, 26 Nov 2023 10:30:44 +0800 Subject: [PATCH 22/77] fix: add minimum selection rect size when drawing shade --- src/view_port.rs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/view_port.rs b/src/view_port.rs index fe35e6da..f0c41270 100644 --- a/src/view_port.rs +++ b/src/view_port.rs @@ -89,6 +89,15 @@ impl Selection { } } + pub fn from_rect(x: f32, y: f32, width: f32, height: f32) -> Self { + Self { + start_x: x, + start_y: y, + end_x: x + width, + end_y: y + height, + } + } + pub fn left_x(&self) -> f32 { self.start_x.min(self.end_x) } @@ -253,7 +262,8 @@ mod imp { if let Some(paintable_rect) = obj.paintable_rect() { snapshot.push_mask(gsk::MaskMode::InvertedAlpha); - snapshot.append_color(&gdk::RGBA::BLACK, &selection_rect); + // Outset so selection rect is never zero sized, avoiding flickering. + snapshot.append_color(&gdk::RGBA::BLACK, &selection_rect.inset_r(-0.1, -0.1)); snapshot.pop(); snapshot.append_color(&SHADE_COLOR, &paintable_rect); From 2e85e53345400115155351345b5e94ad9a14c371 Mon Sep 17 00:00:00 2001 From: Dave Patrick Caberto Date: Sun, 26 Nov 2023 10:48:55 +0800 Subject: [PATCH 23/77] misc: make view port vexpand --- data/resources/ui/win.ui | 1 + 1 file changed, 1 insertion(+) diff --git a/data/resources/ui/win.ui b/data/resources/ui/win.ui index 8ff476d0..893ec2d3 100644 --- a/data/resources/ui/win.ui +++ b/data/resources/ui/win.ui @@ -47,6 +47,7 @@ vertical + True From b2535f7e3e0521f9288e37c488983be5b6a40742 Mon Sep 17 00:00:00 2001 From: Dave Patrick Caberto Date: Sun, 26 Nov 2023 10:53:47 +0800 Subject: [PATCH 24/77] misc: use w and h ceiling --- src/view_port.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/view_port.rs b/src/view_port.rs index f0c41270..9860e9e7 100644 --- a/src/view_port.rs +++ b/src/view_port.rs @@ -239,8 +239,11 @@ mod imp { } else { (widget_height * paintable_ratio, widget_height) }; - let x = (widget_width - width.ceil()) / 2.0; - let y = (widget_height - height.ceil()).floor() / 2.0; + let width = width.ceil(); + let height = height.ceil(); + + let x = (widget_width - width) / 2.0; + let y = (widget_height - height).floor() / 2.0; self.paintable_rect.set(Some(Rect::new( x as f32, From b3d454332ffe374de62e28622f353479c049144a Mon Sep 17 00:00:00 2001 From: Dave Patrick Caberto Date: Sun, 26 Nov 2023 11:32:35 +0800 Subject: [PATCH 25/77] fix: compute paintable rect on size_allocate --- src/view_port.rs | 81 +++++++++++++++++++++++++++++------------------- 1 file changed, 49 insertions(+), 32 deletions(-) diff --git a/src/view_port.rs b/src/view_port.rs index 9860e9e7..f6e3ed0b 100644 --- a/src/view_port.rs +++ b/src/view_port.rs @@ -219,42 +219,57 @@ mod imp { } } + fn size_allocate(&self, widget_width: i32, widget_height: i32, _baseline: i32) { + let Some(paintable) = self.obj().paintable() else { + self.paintable_rect.set(None); + return; + }; + + let widget_width = widget_width as f64; + let widget_height = widget_height as f64; + let widget_ratio = widget_width / widget_height; + + let paintable_width = paintable.intrinsic_width() as f64; + let paintable_height = paintable.intrinsic_height() as f64; + let paintable_ratio = paintable.intrinsic_aspect_ratio(); + + let (width, height) = + if widget_width >= paintable_width && widget_height >= paintable_height { + (paintable_width, paintable_height) + } else if paintable_ratio > widget_ratio { + (widget_width, widget_width / paintable_ratio) + } else { + (widget_height * paintable_ratio, widget_height) + }; + let width = width.ceil(); + let height = height.ceil(); + + let x = (widget_width - width) / 2.0; + let y = (widget_height - height).floor() / 2.0; + + self.paintable_rect.set(Some(Rect::new( + x as f32, + y as f32, + width as f32, + height as f32, + ))); + } + fn snapshot(&self, snapshot: >k::Snapshot) { let obj = self.obj(); - if let Some(paintable) = obj.paintable() { - let widget_width = obj.width() as f64; - let widget_height = obj.height() as f64; - let widget_ratio = widget_width / widget_height; - - let paintable_width = paintable.intrinsic_width() as f64; - let paintable_height = paintable.intrinsic_height() as f64; - let paintable_ratio = paintable.intrinsic_aspect_ratio(); - - let (width, height) = - if widget_width >= paintable_width && widget_height >= paintable_height { - (paintable_width, paintable_height) - } else if paintable_ratio > widget_ratio { - (widget_width, widget_width / paintable_ratio) - } else { - (widget_height * paintable_ratio, widget_height) - }; - let width = width.ceil(); - let height = height.ceil(); - - let x = (widget_width - width) / 2.0; - let y = (widget_height - height).floor() / 2.0; - - self.paintable_rect.set(Some(Rect::new( - x as f32, - y as f32, - width as f32, - height as f32, - ))); - + if let Some(paintable_rect) = obj.paintable_rect() { snapshot.save(); - snapshot.translate(&Point::new(x as f32, y as f32)); - paintable.snapshot(snapshot, width, height); + + snapshot.translate(&Point::new(paintable_rect.x(), paintable_rect.y())); + + let paintable = obj.paintable().unwrap(); + paintable.snapshot( + snapshot, + paintable_rect.width() as f64, + paintable_rect.height() as f64, + ); + snapshot.restore(); } @@ -336,6 +351,8 @@ mod imp { ); } + self.paintable_rect.set(None); + obj.queue_resize(); obj.notify_paintable(); } From d97d5b8d6d695f7635542d0105ce46cff3e0a612 Mon Sep 17 00:00:00 2001 From: Dave Patrick Caberto Date: Sun, 26 Nov 2023 12:08:55 +0800 Subject: [PATCH 26/77] fix: update selection when view port resizes --- src/view_port.rs | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/src/view_port.rs b/src/view_port.rs index f6e3ed0b..d6ac4afb 100644 --- a/src/view_port.rs +++ b/src/view_port.rs @@ -220,7 +220,9 @@ mod imp { } fn size_allocate(&self, widget_width: i32, widget_height: i32, _baseline: i32) { - let Some(paintable) = self.obj().paintable() else { + let obj = self.obj(); + + let Some(paintable) = obj.paintable() else { self.paintable_rect.set(None); return; }; @@ -247,12 +249,33 @@ mod imp { let x = (widget_width - width) / 2.0; let y = (widget_height - height).floor() / 2.0; + let prev_paintable_rect = self.paintable_rect.get(); self.paintable_rect.set(Some(Rect::new( x as f32, y as f32, width as f32, height as f32, ))); + + // Update selection if paintable rect changed + if let Some(prev_paintable_rect) = prev_paintable_rect { + if let Some(selection) = obj.selection() { + let selection_rect = selection.rect(); + + let scale_x = width as f32 / prev_paintable_rect.width(); + let scale_y = height as f32 / prev_paintable_rect.height(); + + let rel_x = selection_rect.x() - prev_paintable_rect.x(); + let rel_y = selection_rect.y() - prev_paintable_rect.y(); + + obj.set_selection(Some(Selection::from_rect( + x as f32 + rel_x * scale_x, + y as f32 + rel_y * scale_y, + selection_rect.width() * scale_x, + selection_rect.height() * scale_y, + ))); + } + } } fn snapshot(&self, snapshot: >k::Snapshot) { From a016bb246a16e36a64ea251fb0b586bdb2b220e5 Mon Sep 17 00:00:00 2001 From: Dave Patrick Caberto Date: Sun, 26 Nov 2023 12:12:25 +0800 Subject: [PATCH 27/77] misc: improve comments --- src/view_port.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/view_port.rs b/src/view_port.rs index d6ac4afb..d0e127bb 100644 --- a/src/view_port.rs +++ b/src/view_port.rs @@ -257,7 +257,7 @@ mod imp { height as f32, ))); - // Update selection if paintable rect changed + // Update selection if paintable rect changed. if let Some(prev_paintable_rect) = prev_paintable_rect { if let Some(selection) = obj.selection() { let selection_rect = selection.rect(); @@ -548,7 +548,7 @@ impl ViewPort { let paintable_rect = self.paintable_rect().unwrap(); let selection_rect = self.selection().unwrap().rect(); - // Keep the size intact if we bumped into the stage edge. + // Keep the size intact if we bumped to the paintable rect. if new_start_x < paintable_rect.x() { overshoot_x = paintable_rect.x() - new_start_x; new_start_x = paintable_rect.x(); @@ -682,7 +682,7 @@ impl ViewPort { let paintable_rect = self.paintable_rect().unwrap(); let selection_rect = selection.rect(); - // Keep the coordinates inside the stage. + // Keep the coordinates inside the paintable rect. if selection.start_x < paintable_rect.x() { selection.start_x = paintable_rect.x(); selection.end_x = selection.start_x + selection_rect.width(); From 14402727558d32297302d54452e6b2a42973bfdf Mon Sep 17 00:00:00 2001 From: Dave Patrick Caberto Date: Sun, 26 Nov 2023 12:59:01 +0800 Subject: [PATCH 28/77] refactor: simplify insetting --- src/view_port.rs | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/src/view_port.rs b/src/view_port.rs index d0e127bb..3d216a1f 100644 --- a/src/view_port.rs +++ b/src/view_port.rs @@ -299,12 +299,14 @@ mod imp { if let Some(selection) = obj.selection() { let selection_rect = selection.rect(); + // Outset so the displayed selection is never zero sized, avoiding flickering. + let selection_rect_display = selection_rect.inset_r(-1.0, -1.0); + // Shades the area outside the selection. if let Some(paintable_rect) = obj.paintable_rect() { snapshot.push_mask(gsk::MaskMode::InvertedAlpha); - // Outset so selection rect is never zero sized, avoiding flickering. - snapshot.append_color(&gdk::RGBA::BLACK, &selection_rect.inset_r(-0.1, -0.1)); + snapshot.append_color(&gdk::RGBA::BLACK, &selection_rect_display); snapshot.pop(); snapshot.append_color(&SHADE_COLOR, &paintable_rect); @@ -312,15 +314,7 @@ mod imp { } snapshot.append_border( - &RoundedRect::from_rect( - Rect::new( - selection_rect.x(), - selection_rect.y(), - selection_rect.width().max(1.0), - selection_rect.height().max(1.0), - ), - 0.0, - ), + &RoundedRect::from_rect(selection_rect_display, 0.0), &[SELECTION_LINE_WIDTH; 4], &[SELECTION_COLOR; 4], ); From 8127fd284f5d28a90a4a76bf497a615ca7f933bf Mon Sep 17 00:00:00 2001 From: Dave Patrick Caberto Date: Sun, 26 Nov 2023 13:04:21 +0800 Subject: [PATCH 29/77] refactor: make selection an actual property --- src/view_port.rs | 24 +++++++++++++++--------- src/win.rs | 2 +- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/src/view_port.rs b/src/view_port.rs index 3d216a1f..da33fc93 100644 --- a/src/view_port.rs +++ b/src/view_port.rs @@ -58,7 +58,7 @@ impl CursorType { } } -#[derive(Default, Clone, Copy, glib::Boxed)] +#[derive(Default, Clone, Copy, PartialEq, glib::Boxed)] #[boxed_type(name = "KoohaSelection", nullable)] pub struct Selection { start_x: f32, @@ -132,7 +132,7 @@ mod imp { pub struct ViewPort { #[property(get, set = Self::set_paintable, explicit_notify, nullable)] pub(super) paintable: RefCell>, - #[property(get)] + #[property(get, set = Self::set_selection, explicit_notify, nullable)] pub(super) selection: Cell>, pub(super) paintable_rect: Cell>, @@ -373,6 +373,19 @@ mod imp { obj.queue_resize(); obj.notify_paintable(); } + + fn set_selection(&self, selection: Option) { + let obj = self.obj(); + + if selection == obj.selection() { + return; + } + + self.selection.set(selection); + obj.update_selection_handles(); + obj.queue_draw(); + obj.notify_selection(); + } } } @@ -390,13 +403,6 @@ impl ViewPort { self.imp().paintable_rect.get() } - pub fn set_selection(&self, selection: Option) { - self.imp().selection.set(selection); - self.update_selection_handles(); - self.queue_draw(); - self.notify_selection(); - } - fn on_enter(&self, _controller: >k::EventControllerMotion, x: f64, y: f64) { let imp = self.imp(); diff --git a/src/win.rs b/src/win.rs index 9e87efac..7eaaba04 100644 --- a/src/win.rs +++ b/src/win.rs @@ -103,7 +103,7 @@ mod imp { }); imp.view_port.set_selection(Some(selection)); } else { - imp.view_port.set_selection(None); + imp.view_port.set_selection(None::); } })); self.view_port From 5bf5b664b4b9da5b9adc4838138df6626812594f Mon Sep 17 00:00:00 2001 From: Dave Patrick Caberto Date: Sun, 26 Nov 2023 13:10:30 +0800 Subject: [PATCH 30/77] misc: drop useless freeze_notify --- src/view_port.rs | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/view_port.rs b/src/view_port.rs index da33fc93..7f0620b1 100644 --- a/src/view_port.rs +++ b/src/view_port.rs @@ -345,8 +345,6 @@ mod imp { return; } - let _freeze_guard = obj.freeze_notify(); - let mut handler_ids = self.handler_ids.borrow_mut(); if let Some(previous_paintable) = self.paintable.replace(paintable.clone()) { From b1eab1cd31750c5a6129e1b3d366d9ae7c5931e8 Mon Sep 17 00:00:00 2001 From: Dave Patrick Caberto Date: Sun, 26 Nov 2023 13:17:23 +0800 Subject: [PATCH 31/77] refactor: use template callbacks --- data/resources/resources.gresource.xml | 1 + data/resources/ui/view-port.ui | 19 ++ src/view_port.rs | 245 ++++++++++++------------- 3 files changed, 137 insertions(+), 128 deletions(-) create mode 100644 data/resources/ui/view-port.ui diff --git a/data/resources/resources.gresource.xml b/data/resources/resources.gresource.xml index dba9c7ce..e8c95b1c 100644 --- a/data/resources/resources.gresource.xml +++ b/data/resources/resources.gresource.xml @@ -19,6 +19,7 @@ ui/area-selector.ui ui/preferences-window.ui ui/shortcuts.ui + ui/view-port.ui ui/win.ui ui/window.ui diff --git a/data/resources/ui/view-port.ui b/data/resources/ui/view-port.ui new file mode 100644 index 00000000..a9a3eb2e --- /dev/null +++ b/data/resources/ui/view-port.ui @@ -0,0 +1,19 @@ + + + + diff --git a/src/view_port.rs b/src/view_port.rs index 7f0620b1..9f51314c 100644 --- a/src/view_port.rs +++ b/src/view_port.rs @@ -127,7 +127,8 @@ impl Selection { mod imp { use super::*; - #[derive(Debug, Default, glib::Properties)] + #[derive(Debug, Default, glib::Properties, gtk::CompositeTemplate)] + #[template(resource = "/io/github/seadve/Kooha/ui/view-port.ui")] #[properties(wrapper_type = super::ViewPort)] pub struct ViewPort { #[property(get, set = Self::set_paintable, explicit_notify, nullable)] @@ -151,41 +152,20 @@ mod imp { const NAME: &'static str = "KoohaViewPort"; type Type = super::ViewPort; type ParentType = gtk::Widget; - } - - #[glib::derived_properties] - impl ObjectImpl for ViewPort { - fn constructed(&self) { - self.parent_constructed(); - let obj = self.obj(); - - let motion_controller = gtk::EventControllerMotion::new(); - motion_controller.connect_enter(clone!(@weak obj => move |controller, x, y| { - obj.on_enter(controller, x, y); - })); - motion_controller.connect_motion(clone!(@weak obj => move |controller, x, y| { - obj.on_motion(controller, x, y); - })); - motion_controller.connect_leave(clone!(@weak obj => move |controller| { - obj.on_leave(controller); - })); - obj.add_controller(motion_controller); + fn class_init(klass: &mut Self::Class) { + klass.bind_template(); + klass.bind_template_instance_callbacks(); + } - let gesture_drag = gtk::GestureDrag::builder().exclusive(true).build(); - gesture_drag.connect_drag_begin(clone!(@weak obj => move |controller, x, y| { - obj.on_drag_begin(controller, x, y); - })); - gesture_drag.connect_drag_update(clone!(@weak obj => move |controller, dx, dy| { - obj.on_drag_update(controller, dx, dy); - })); - gesture_drag.connect_drag_end(clone!(@weak obj => move |controller, dx, dy| { - obj.on_drag_end(controller, dx, dy); - })); - obj.add_controller(gesture_drag); + fn instance_init(obj: &glib::subclass::InitializingObject) { + obj.init_template(); } } + #[glib::derived_properties] + impl ObjectImpl for ViewPort {} + impl WidgetImpl for ViewPort { fn request_mode(&self) -> gtk::SizeRequestMode { gtk::SizeRequestMode::HeightForWidth @@ -201,7 +181,7 @@ mod imp { }; if orientation == gtk::Orientation::Horizontal { - let (natural_width, _natural_height) = paintable.compute_concrete_size( + let (natural_width, _) = paintable.compute_concrete_size( 0.0, if for_size < 0 { 0.0 } else { for_size as f64 }, DEFAULT_SIZE, @@ -209,7 +189,7 @@ mod imp { ); (0, natural_width.ceil() as i32, -1, -1) } else { - let (_natural_width, natural_height) = paintable.compute_concrete_size( + let (_, natural_height) = paintable.compute_concrete_size( if for_size < 0 { 0.0 } else { for_size as f64 }, 0.0, DEFAULT_SIZE, @@ -401,14 +381,108 @@ impl ViewPort { self.imp().paintable_rect.get() } - fn on_enter(&self, _controller: >k::EventControllerMotion, x: f64, y: f64) { + fn set_cursor(&self, cursor_type: CursorType) { + self.set_cursor_from_name(Some(cursor_type.name())); + } + + fn compute_cursor_type(&self, x: f32, y: f32) -> CursorType { + let imp = self.imp(); + + let point = Point::new(x, y); + + let Some(selection) = self.selection() else { + return CursorType::Crosshair; + }; + + let [top_left_handle, top_right_handle, bottom_right_handle, bottom_left_handle] = + imp.selection_handles.get().unwrap(); + + if top_left_handle.contains_point(&point) { + CursorType::NorthWestResize + } else if top_right_handle.contains_point(&point) { + CursorType::NorthEastResize + } else if bottom_right_handle.contains_point(&point) { + CursorType::SouthEastResize + } else if bottom_left_handle.contains_point(&point) { + CursorType::SouthWestResize + } else if selection.rect().contains_point(&point) { + CursorType::Move + } else if top_left_handle + .union(&top_right_handle) + .contains_point(&point) + { + CursorType::NorthResize + } else if top_right_handle + .union(&bottom_right_handle) + .contains_point(&point) + { + CursorType::EastResize + } else if bottom_right_handle + .union(&bottom_left_handle) + .contains_point(&point) + { + CursorType::SouthResize + } else if bottom_left_handle + .union(&top_left_handle) + .contains_point(&point) + { + CursorType::WestResize + } else { + CursorType::Crosshair + } + } + + fn update_selection_handles(&self) { + let imp = self.imp(); + + let Some(selection) = self.selection() else { + imp.selection_handles.set(None); + return; + }; + + let selection_handle_diameter = SELECTION_HANDLE_RADIUS * 2.0; + let top_left = Rect::new( + selection.left_x() - SELECTION_HANDLE_RADIUS, + selection.top_y() - SELECTION_HANDLE_RADIUS, + selection_handle_diameter, + selection_handle_diameter, + ); + let top_right = Rect::new( + selection.right_x() - SELECTION_HANDLE_RADIUS, + selection.top_y() - SELECTION_HANDLE_RADIUS, + selection_handle_diameter, + selection_handle_diameter, + ); + let bottom_right = Rect::new( + selection.right_x() - SELECTION_HANDLE_RADIUS, + selection.bottom_y() - SELECTION_HANDLE_RADIUS, + selection_handle_diameter, + selection_handle_diameter, + ); + let bottom_left = Rect::new( + selection.left_x() - SELECTION_HANDLE_RADIUS, + selection.bottom_y() - SELECTION_HANDLE_RADIUS, + selection_handle_diameter, + selection_handle_diameter, + ); + + imp.selection_handles + .set(Some([top_left, top_right, bottom_right, bottom_left])); + } +} + +#[gtk::template_callbacks] +impl ViewPort { + #[template_callback] + fn enter(&self, x: f64, y: f64) { let imp = self.imp(); imp.pointer_position .set(Some(Point::new(x as f32, y as f32))); } - fn on_motion(&self, _controller: >k::EventControllerMotion, x: f64, y: f64) { + #[template_callback] + fn motion(&self, x: f64, y: f64) { let imp = self.imp(); imp.pointer_position @@ -420,7 +494,8 @@ impl ViewPort { } } - fn on_leave(&self, _controller: >k::EventControllerMotion) { + #[template_callback] + fn leave(&self) { let imp = self.imp(); imp.pointer_position.set(None); @@ -428,7 +503,8 @@ impl ViewPort { self.set_cursor(CursorType::Default); } - fn on_drag_begin(&self, _gesture: >k::GestureDrag, x: f64, y: f64) { + #[template_callback] + fn drag_begin(&self, x: f64, y: f64) { tracing::trace!("Drag begin at ({}, {})", x, y); let imp = self.imp(); @@ -499,7 +575,8 @@ impl ViewPort { } } - fn on_drag_update(&self, _gesture: >k::GestureDrag, _: f64, _: f64) { + #[template_callback] + fn drag_update(&self, _dx: f64, _dy: f64) { let imp = self.imp(); let pointer_position = imp.pointer_position.get().unwrap(); @@ -658,7 +735,8 @@ impl ViewPort { } } - fn on_drag_end(&self, _gesture: >k::GestureDrag, dx: f64, dy: f64) { + #[template_callback] + fn drag_end(&self, dx: f64, dy: f64) { tracing::trace!("Drag end offset ({}, {})", dx, dy); let imp = self.imp(); @@ -705,95 +783,6 @@ impl ViewPort { self.set_cursor(cursor_type); } } - - fn set_cursor(&self, cursor_type: CursorType) { - self.set_cursor_from_name(Some(cursor_type.name())); - } - - fn compute_cursor_type(&self, x: f32, y: f32) -> CursorType { - let imp = self.imp(); - - let point = Point::new(x, y); - - let Some(selection) = self.selection() else { - return CursorType::Crosshair; - }; - - let [top_left_handle, top_right_handle, bottom_right_handle, bottom_left_handle] = - imp.selection_handles.get().unwrap(); - - if top_left_handle.contains_point(&point) { - CursorType::NorthWestResize - } else if top_right_handle.contains_point(&point) { - CursorType::NorthEastResize - } else if bottom_right_handle.contains_point(&point) { - CursorType::SouthEastResize - } else if bottom_left_handle.contains_point(&point) { - CursorType::SouthWestResize - } else if selection.rect().contains_point(&point) { - CursorType::Move - } else if top_left_handle - .union(&top_right_handle) - .contains_point(&point) - { - CursorType::NorthResize - } else if top_right_handle - .union(&bottom_right_handle) - .contains_point(&point) - { - CursorType::EastResize - } else if bottom_right_handle - .union(&bottom_left_handle) - .contains_point(&point) - { - CursorType::SouthResize - } else if bottom_left_handle - .union(&top_left_handle) - .contains_point(&point) - { - CursorType::WestResize - } else { - CursorType::Crosshair - } - } - - fn update_selection_handles(&self) { - let imp = self.imp(); - - let Some(selection) = self.selection() else { - imp.selection_handles.set(None); - return; - }; - - let selection_handle_diameter = SELECTION_HANDLE_RADIUS * 2.0; - let top_left = Rect::new( - selection.left_x() - SELECTION_HANDLE_RADIUS, - selection.top_y() - SELECTION_HANDLE_RADIUS, - selection_handle_diameter, - selection_handle_diameter, - ); - let top_right = Rect::new( - selection.right_x() - SELECTION_HANDLE_RADIUS, - selection.top_y() - SELECTION_HANDLE_RADIUS, - selection_handle_diameter, - selection_handle_diameter, - ); - let bottom_right = Rect::new( - selection.right_x() - SELECTION_HANDLE_RADIUS, - selection.bottom_y() - SELECTION_HANDLE_RADIUS, - selection_handle_diameter, - selection_handle_diameter, - ); - let bottom_left = Rect::new( - selection.left_x() - SELECTION_HANDLE_RADIUS, - selection.bottom_y() - SELECTION_HANDLE_RADIUS, - selection_handle_diameter, - selection_handle_diameter, - ); - - imp.selection_handles - .set(Some([top_left, top_right, bottom_right, bottom_left])); - } } impl Default for ViewPort { From db381117a3aacf823a8f8be1639156142eded364 Mon Sep 17 00:00:00 2001 From: Dave Patrick Caberto Date: Sun, 26 Nov 2023 13:18:22 +0800 Subject: [PATCH 32/77] refactor: use glib::Object::new where applicable --- src/area_selector/mod.rs | 2 +- src/area_selector/view_port.rs | 2 +- src/profile.rs | 2 +- src/recording.rs | 2 +- src/toggle_button.rs | 2 +- src/view_port.rs | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/area_selector/mod.rs b/src/area_selector/mod.rs index 0fc74ff0..719aa08f 100644 --- a/src/area_selector/mod.rs +++ b/src/area_selector/mod.rs @@ -150,7 +150,7 @@ impl AreaSelector { fd: RawFd, streams: &[Stream], ) -> Result { - let this: Self = glib::Object::builder().build(); + let this: Self = glib::Object::new(); let imp = this.imp(); // Setup window size and transient for diff --git a/src/area_selector/view_port.rs b/src/area_selector/view_port.rs index c9a473c5..ae9735d9 100644 --- a/src/area_selector/view_port.rs +++ b/src/area_selector/view_port.rs @@ -363,7 +363,7 @@ glib::wrapper! { impl ViewPort { pub fn new() -> Self { - glib::Object::builder().build() + glib::Object::new() } pub fn paintable_rect(&self) -> Option { diff --git a/src/profile.rs b/src/profile.rs index 6625cbbe..4f7dc2cb 100644 --- a/src/profile.rs +++ b/src/profile.rs @@ -66,7 +66,7 @@ impl BoxedProfile { } fn new_inner(profile: Option>) -> Self { - let this: Self = glib::Object::builder().build(); + let this: Self = glib::Object::new(); this.imp().0.set(profile).unwrap(); this } diff --git a/src/recording.rs b/src/recording.rs index 5bbd30d9..cc7cca0d 100644 --- a/src/recording.rs +++ b/src/recording.rs @@ -124,7 +124,7 @@ glib::wrapper! { impl Recording { pub fn new() -> Self { - glib::Object::builder().build() + glib::Object::new() } pub async fn start(&self, parent: Option<&impl IsA>, settings: &Settings) { diff --git a/src/toggle_button.rs b/src/toggle_button.rs index 1a7a4ef6..b4ffbf89 100644 --- a/src/toggle_button.rs +++ b/src/toggle_button.rs @@ -111,7 +111,7 @@ glib::wrapper! { impl ToggleButton { pub fn new() -> Self { - glib::Object::builder().build() + glib::Object::new() } fn update_icon_name(&self) { diff --git a/src/view_port.rs b/src/view_port.rs index 9f51314c..e022f03c 100644 --- a/src/view_port.rs +++ b/src/view_port.rs @@ -374,7 +374,7 @@ glib::wrapper! { impl ViewPort { pub fn new() -> Self { - glib::Object::builder().build() + glib::Object::new() } pub fn paintable_rect(&self) -> Option { From 4f8731adea4307883ccc080e286c971e9fea454d Mon Sep 17 00:00:00 2001 From: Dave Patrick Caberto Date: Sun, 26 Nov 2023 13:21:45 +0800 Subject: [PATCH 33/77] refactor: avoid dual access to selection --- src/view_port.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/view_port.rs b/src/view_port.rs index e022f03c..8fdcc1ff 100644 --- a/src/view_port.rs +++ b/src/view_port.rs @@ -534,7 +534,7 @@ impl ViewPort { imp.drag_start.set(Some(Point::new(x as f32, y as f32))); let selection = self.selection().unwrap(); - let mut new_selection = self.selection().unwrap(); + let mut new_selection = selection; if cursor_type == CursorType::Move { new_selection.start_x = selection.left_x(); From 6b12f7467fb91a07837fd9a1c35ece9ed6488ca1 Mon Sep 17 00:00:00 2001 From: Dave Patrick Caberto Date: Sun, 26 Nov 2023 13:29:10 +0800 Subject: [PATCH 34/77] fix: don't panic on drag when missing a paintable --- src/view_port.rs | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/src/view_port.rs b/src/view_port.rs index 8fdcc1ff..78007154 100644 --- a/src/view_port.rs +++ b/src/view_port.rs @@ -390,6 +390,10 @@ impl ViewPort { let point = Point::new(x, y); + if self.paintable().is_none() { + return CursorType::Default; + }; + let Some(selection) = self.selection() else { return CursorType::Crosshair; }; @@ -507,6 +511,10 @@ impl ViewPort { fn drag_begin(&self, x: f64, y: f64) { tracing::trace!("Drag begin at ({}, {})", x, y); + let Some(paintable_rect) = self.paintable_rect() else { + return; + }; + let imp = self.imp(); let cursor_type = self.compute_cursor_type(x as f32, y as f32); @@ -514,7 +522,6 @@ impl ViewPort { imp.drag_cursor.set(CursorType::Crosshair); self.set_cursor(CursorType::Crosshair); - let paintable_rect = self.paintable_rect().unwrap(); let x = (x as f32).clamp( paintable_rect.x(), paintable_rect.x() + paintable_rect.width(), @@ -577,6 +584,10 @@ impl ViewPort { #[template_callback] fn drag_update(&self, _dx: f64, _dy: f64) { + let Some(paintable_rect) = self.paintable_rect() else { + return; + }; + let imp = self.imp(); let pointer_position = imp.pointer_position.get().unwrap(); @@ -587,7 +598,6 @@ impl ViewPort { let Selection { start_x, start_y, .. } = self.selection().unwrap(); - let paintable_rect = self.paintable_rect().unwrap(); self.set_selection(Some(Selection { start_x, start_y, @@ -620,7 +630,6 @@ impl ViewPort { let mut overshoot_x = 0.0; let mut overshoot_y = 0.0; - let paintable_rect = self.paintable_rect().unwrap(); let selection_rect = self.selection().unwrap().rect(); // Keep the size intact if we bumped to the paintable rect. @@ -663,7 +672,6 @@ impl ViewPort { dx = 0.0; } - let paintable_rect = self.paintable_rect().unwrap(); let mut new_selection = self.selection().unwrap(); new_selection.end_x += dx; @@ -739,6 +747,10 @@ impl ViewPort { fn drag_end(&self, dx: f64, dy: f64) { tracing::trace!("Drag end offset ({}, {})", dx, dy); + let Some(paintable_rect) = self.paintable_rect() else { + return; + }; + let imp = self.imp(); imp.drag_start.set(None); @@ -755,7 +767,6 @@ impl ViewPort { selection.end_x += offset; selection.end_y += offset; - let paintable_rect = self.paintable_rect().unwrap(); let selection_rect = selection.rect(); // Keep the coordinates inside the paintable rect. From ff21b586e71a2e7382a48596c364c350416a4b94 Mon Sep 17 00:00:00 2001 From: Dave Patrick Caberto Date: Sun, 26 Nov 2023 14:16:49 +0800 Subject: [PATCH 35/77] refactor: rename to prev --- src/win.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/win.rs b/src/win.rs index 7eaaba04..02ce69a4 100644 --- a/src/win.rs +++ b/src/win.rs @@ -45,7 +45,7 @@ mod imp { pub(super) session: RefCell>, pub(super) stream_size: Cell>, - pub(super) previous_selection: Cell>, + pub(super) prev_selection: Cell>, pub(super) desktop_audio_pipeline: RefCell>, pub(super) microphone_pipeline: RefCell>, @@ -90,7 +90,7 @@ mod imp { .connect_active_notify(clone!(@weak obj => move |toggle| { let imp = obj.imp(); if toggle.is_active() { - let selection = obj.imp().previous_selection.get().unwrap_or_else(|| { + let selection = obj.imp().prev_selection.get().unwrap_or_else(|| { let mid_x = imp.view_port.width() as f32 / 2.0; let mid_y = imp.view_port.height() as f32 / 2.0; let offset = 20.0 * imp.view_port.scale_factor() as f32; @@ -114,7 +114,7 @@ mod imp { self.view_port .connect_selection_notify(clone!(@weak obj => move |view_port| { if let Some(selection) = view_port.selection() { - obj.imp().previous_selection.replace(Some(selection)); + obj.imp().prev_selection.replace(Some(selection)); } obj.update_selection_toggle(); obj.update_info_label(); From a5b24fdbbabc2ae63f36699b57e48e522755895b Mon Sep 17 00:00:00 2001 From: Dave Patrick Caberto Date: Sun, 26 Nov 2023 14:17:40 +0800 Subject: [PATCH 36/77] misc: add todo --- src/view_port.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/view_port.rs b/src/view_port.rs index 78007154..04eb0659 100644 --- a/src/view_port.rs +++ b/src/view_port.rs @@ -15,6 +15,10 @@ use std::{ fmt, }; +// TODO +// * Handle selection outside paintable rect, when setting selection. +// * Add animation when entering/leaving selection mode. + const DEFAULT_SIZE: f64 = 100.0; const SHADE_COLOR: gdk::RGBA = gdk::RGBA::new(0.0, 0.0, 0.0, 0.5); From 875c2949bd46f0fc97dbf8df60955a99c1aa22a4 Mon Sep 17 00:00:00 2001 From: Dave Patrick Caberto Date: Sun, 26 Nov 2023 14:31:19 +0800 Subject: [PATCH 37/77] misc: improve viewport styling --- data/resources/style.css | 2 +- data/resources/ui/view-port.ui | 3 +++ data/resources/ui/win.ui | 1 + 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/data/resources/style.css b/data/resources/style.css index 7590fed8..696cc015 100644 --- a/data/resources/style.css +++ b/data/resources/style.css @@ -1,5 +1,5 @@ .view-port { - background-color: #3d3846; + padding: 6px; } .red { diff --git a/data/resources/ui/view-port.ui b/data/resources/ui/view-port.ui index a9a3eb2e..17144e82 100644 --- a/data/resources/ui/view-port.ui +++ b/data/resources/ui/view-port.ui @@ -1,6 +1,9 @@ - diff --git a/data/resources/ui/win.ui b/data/resources/ui/win.ui deleted file mode 100644 index b1ee3ff8..00000000 --- a/data/resources/ui/win.ui +++ /dev/null @@ -1,233 +0,0 @@ - - - -
- - _Preferences - app.show-preferences - - - _Keyboard Shortcuts - win.show-help-overlay - - - _About Kooha - app.show-about - -
-
- - - - - - - -
diff --git a/data/resources/ui/window.ui b/data/resources/ui/window.ui index 1f1302d0..b4fdcc42 100644 --- a/data/resources/ui/window.ui +++ b/data/resources/ui/window.ui @@ -1,84 +1,119 @@ - - + +
+ + _Preferences + app.show-preferences + + + _Keyboard Shortcuts + win.show-help-overlay + + + _About Kooha + app.show-about + +
+
- -
- - _Preferences - app.show-preferences - - - _Keyboard Shortcuts - win.show-help-overlay - - - _About Kooha - app.show-about - -
-
+ + + + + +
diff --git a/src/application.rs b/src/application.rs index 202c2e5b..9f43f76c 100644 --- a/src/application.rs +++ b/src/application.rs @@ -11,7 +11,7 @@ use crate::{ config::{APP_ID, PKGDATADIR, PROFILE, VERSION}, preferences_window::PreferencesWindow, settings::Settings, - win::Win, + window::Window, }; mod imp { @@ -21,7 +21,7 @@ mod imp { #[derive(Debug, Default)] pub struct Application { - pub(super) window: OnceCell>, + pub(super) window: OnceCell>, pub(super) settings: OnceCell, } @@ -46,7 +46,7 @@ mod imp { return; } - let window = Win::new(&obj); + let window = Window::new(&obj); self.window.set(window.downgrade()).unwrap(); window.present(); } @@ -95,7 +95,7 @@ impl Application { }) } - pub fn window(&self) -> Win { + pub fn window(&self) -> Window { self.imp() .window .get() diff --git a/src/area_selector/mod.rs b/src/area_selector/mod.rs deleted file mode 100644 index f70f2038..00000000 --- a/src/area_selector/mod.rs +++ /dev/null @@ -1,332 +0,0 @@ -mod view_port; - -use adw::{prelude::*, subclass::prelude::*}; -use anyhow::{Context, Result}; -use futures_channel::oneshot::{self, Sender}; -use gettextrs::gettext; -use gst::prelude::*; -use gtk::{ - gdk, - glib::{self, clone}, - graphene::Rect, -}; - -use std::{cell::RefCell, os::unix::prelude::RawFd}; - -use crate::{cancelled::Cancelled, pipe, screencast_session::Stream}; - -const PREVIEW_FRAMERATE: u32 = 60; -const ASSUMED_HEADER_BAR_HEIGHT: f64 = 47.0; - -pub use self::view_port::{Selection, ViewPort}; - -#[derive(Debug)] -pub struct Data { - /// Selection relative to paintable_rect - pub selection: Selection, - /// The geometry of paintable where the stream is displayed - pub paintable_rect: Rect, - /// Actual stream size - pub stream_size: (i32, i32), -} - -mod imp { - use std::cell::OnceCell; - - use super::*; - use gst::bus::BusWatchGuard; - use gtk::CompositeTemplate; - - #[derive(Debug, Default, CompositeTemplate)] - #[template(resource = "/io/github/seadve/Kooha/ui/area-selector.ui")] - pub struct AreaSelector { - #[template_child] - pub(super) window_title: TemplateChild, - #[template_child] - pub(super) done_button: TemplateChild, - #[template_child] - pub(super) stack: TemplateChild, - #[template_child] - pub(super) loading: TemplateChild, - #[template_child] - pub(super) view_port: TemplateChild, - - pub(super) pipeline: OnceCell, - pub(super) stream_size: OnceCell<(i32, i32)>, - pub(super) result_tx: RefCell>>>, - pub(super) async_done_tx: RefCell>>>, - pub(super) bus_watch_guard: OnceCell, - } - - #[glib::object_subclass] - impl ObjectSubclass for AreaSelector { - const NAME: &'static str = "KoohaAreaSelector"; - type Type = super::AreaSelector; - type ParentType = adw::Window; - - fn class_init(klass: &mut Self::Class) { - klass.bind_template(); - - klass.install_action("area-selector.cancel", None, move |obj, _, _| { - if let Some(sender) = obj.imp().async_done_tx.take() { - let _ = sender.send(Err(Cancelled::new("area select loading"))); - } - - if let Some(sender) = obj.imp().result_tx.take() { - let _ = sender.send(Err(Cancelled::new("area select"))); - obj.close(); - } else { - tracing::error!("Sent result twice"); - } - }); - - klass.install_action("area-selector.done", None, move |obj, _, _| { - if let Some(sender) = obj.imp().result_tx.take() { - let _ = sender.send(Ok(())); - obj.close(); - } else { - tracing::error!("Sent response twice"); - } - }); - - klass.install_action("area-selector.reset", None, move |obj, _, _| { - obj.imp().view_port.set_selection(None); - }); - - klass.add_binding_action( - gdk::Key::Escape, - gdk::ModifierType::empty(), - "area-selector.cancel", - None, - ); - } - - fn instance_init(obj: &glib::subclass::InitializingObject) { - obj.init_template(); - } - } - - impl ObjectImpl for AreaSelector { - fn constructed(&self) { - self.parent_constructed(); - - let obj = self.obj(); - - self.view_port - .connect_selection_notify(clone!(@weak obj => move |_| { - obj.update_selection_ui(); - })); - - let done_button = self.done_button.get(); - obj.set_default_widget(Some(&done_button)); - obj.set_focus_widget(Some(&done_button)); - - obj.update_selection_ui(); - } - - fn dispose(&self) { - if let Some(pipeline) = self.pipeline.get() { - if let Err(err) = pipeline.set_state(gst::State::Null) { - tracing::warn!("Failed to set pipeline to Null: {}", err); - } - } - } - } - - impl WidgetImpl for AreaSelector {} - impl WindowImpl for AreaSelector {} - impl AdwWindowImpl for AreaSelector {} -} - -glib::wrapper! { - pub struct AreaSelector(ObjectSubclass) - @extends gtk::Widget, gtk::Window, adw::Window, - @implements gtk::Native; -} - -impl AreaSelector { - pub async fn present( - transient_for: Option<&impl IsA>, - fd: RawFd, - streams: &[Stream], - ) -> Result { - let this: Self = glib::Object::new(); - let imp = this.imp(); - - // Setup window size and transient for - if let Some(transient_for) = transient_for { - let transient_for = transient_for.as_ref(); - - this.set_transient_for(Some(transient_for)); - this.set_modal(true); - - let scale_factor = 0.4 / transient_for.scale_factor() as f64; - let monitor_geometry = RootExt::display(transient_for) - .monitor_at_surface(&transient_for.surface()) - .context("No monitor found")? - .geometry(); - this.set_default_width( - (monitor_geometry.width() as f64 * scale_factor - ASSUMED_HEADER_BAR_HEIGHT * 2.0) - as i32, - ); - this.set_default_height((monitor_geometry.height() as f64 * scale_factor) as i32); - } - - imp.stack.set_visible_child(&imp.loading.get()); - - let (result_tx, result_rx) = oneshot::channel(); - imp.result_tx.replace(Some(result_tx)); - - // Setup pipeline - let pipeline = gst::Pipeline::new(); - let videosrc_bin = pipe::pipewiresrc_bin(fd, streams, PREVIEW_FRAMERATE, None)?; - let sink = gst::ElementFactory::make("gtk4paintablesink").build()?; - pipeline.add_many([videosrc_bin.upcast_ref(), &sink])?; - videosrc_bin.link(&sink)?; - imp.pipeline.set(pipeline.clone()).unwrap(); - - // Setup paintable - let paintable = sink.property::("paintable"); - imp.view_port.set_paintable(Some(paintable)); - - pipeline.set_state(gst::State::Playing)?; - - let (async_done_tx, async_done_rx) = oneshot::channel(); - imp.async_done_tx.replace(Some(async_done_tx)); - - // Setup bus to receive async done message - let bus_watch_guard = pipeline - .bus() - .unwrap() - .add_watch_local( - clone!(@weak this as obj => @default-return glib::ControlFlow::Break, move |_, message| { - obj.handle_bus_message(message) - }), - ) - .unwrap(); - imp.bus_watch_guard.set(bus_watch_guard).unwrap(); - - this.present(); - - // Wait for pipeline to be on playing state - async_done_rx.await.unwrap()?; - - imp.stack.set_visible_child(&imp.view_port.get()); - - // Get stream size - let caps = videosrc_bin - .static_pad("src") - .context("Videosrc bin has no src pad")? - .current_caps() - .context("Videosrc bin src pad has no currentcaps")?; - let caps_struct = caps - .structure(0) - .context("Videosrc bin src pad caps has no structure")?; - let stream_width = caps_struct.get::("width")?; - let stream_height = caps_struct.get::("height")?; - imp.stream_size.set((stream_width, stream_height)).unwrap(); - this.update_selection_ui(); - - // Wait for user response - result_rx.await.unwrap()?; - - Ok(Data { - selection: imp.view_port.selection().unwrap(), - paintable_rect: imp.view_port.paintable_rect().unwrap(), - stream_size: (stream_width, stream_height), - }) - } - - fn handle_bus_message(&self, message: &gst::Message) -> glib::ControlFlow { - use gst::MessageView; - - let imp = self.imp(); - - match message.view() { - MessageView::AsyncDone(_) => { - if let Some(async_done_tx) = imp.async_done_tx.take() { - let _ = async_done_tx.send(Ok(())); - } - - glib::ControlFlow::Continue - } - MessageView::Eos(_) => { - tracing::debug!("Eos signal received from record bus"); - - glib::ControlFlow::Break - } - MessageView::StateChanged(sc) => { - let new_state = sc.current(); - - if message.src() - != imp - .pipeline - .get() - .map(|pipeline| pipeline.upcast_ref::()) - { - tracing::trace!( - "`{}` changed state from `{:?}` -> `{:?}`", - message - .src() - .map_or_else(|| "".into(), |e| e.name()), - sc.old(), - new_state, - ); - return glib::ControlFlow::Continue; - } - - tracing::debug!( - "Pipeline changed state from `{:?}` -> `{:?}`", - sc.old(), - new_state, - ); - - glib::ControlFlow::Continue - } - MessageView::Error(e) => { - tracing::error!("Received error message on bus: {:?}", e); - glib::ControlFlow::Break - } - MessageView::Warning(w) => { - tracing::warn!("Received warning message on bus: {:?}", w); - glib::ControlFlow::Continue - } - MessageView::Info(i) => { - tracing::debug!("Received info message on bus: {:?}", i); - glib::ControlFlow::Continue - } - other => { - tracing::trace!("Received other message on bus: {:?}", other); - glib::ControlFlow::Continue - } - } - } - - fn update_selection_ui(&self) { - let imp = self.imp(); - let view_port = imp.view_port.get(); - - let selection = view_port.selection(); - - self.action_set_enabled("area-selector.reset", selection.is_some()); - self.action_set_enabled("area-selector.done", selection.is_some()); - - if let (Some(stream_size), Some(selection)) = (imp.stream_size.get(), selection) { - let paintable_rect = view_port.paintable_rect().unwrap(); - - let (stream_width, stream_height) = stream_size; - let scale_factor_h = *stream_width as f32 / paintable_rect.width(); - let scale_factor_v = *stream_height as f32 / paintable_rect.height(); - - let selection_rect_scaled = selection.rect().scale(scale_factor_h, scale_factor_v); - imp.window_title.set_subtitle(&format!( - "{} {}×{}", - gettext("approx."), - selection_rect_scaled.width().round() as i32, - selection_rect_scaled.height().round() as i32, - )); - } else { - imp.window_title.set_subtitle(""); - } - } -} diff --git a/src/area_selector/view_port.rs b/src/area_selector/view_port.rs deleted file mode 100644 index ae9735d9..00000000 --- a/src/area_selector/view_port.rs +++ /dev/null @@ -1,779 +0,0 @@ -// Based on gnome-shell's screenshot ui (GPLv2). -// Source: https://gitlab.gnome.org/GNOME/gnome-shell/-/blob/a3c84ca7463ed92b5be6f013a12bce927223f7c5/js/ui/screenshot.js - -use gtk::{ - gdk, - glib::{self, clone}, - graphene::{Point, Rect}, - gsk::RoundedRect, - prelude::*, - subclass::prelude::*, -}; - -use std::{ - cell::{Cell, RefCell}, - fmt, -}; - -const DEFAULT_SIZE: f64 = 100.0; - -const SELECTION_COLOR: gdk::RGBA = gdk::RGBA::WHITE; -const SELECTION_HANDLE_RADIUS: f32 = 12.0; -const SELECTION_LINE_WIDTH: f32 = 2.0; - -#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] -enum CursorType { - #[default] - Default, - Crosshair, - Move, - NorthResize, - SouthResize, - EastResize, - WestResize, - NorthEastResize, - NorthWestResize, - SouthEastResize, - SouthWestResize, -} - -impl CursorType { - fn name(self) -> &'static str { - match self { - Self::Default => "default", - Self::Crosshair => "crosshair", - Self::Move => "move", - Self::NorthResize => "n-resize", - Self::SouthResize => "s-resize", - Self::EastResize => "e-resize", - Self::WestResize => "w-resize", - Self::NorthEastResize => "ne-resize", - Self::NorthWestResize => "nw-resize", - Self::SouthEastResize => "se-resize", - Self::SouthWestResize => "sw-resize", - } - } -} - -#[derive(Default, Clone, Copy, glib::Boxed)] -#[boxed_type(name = "KoohaSelection", nullable)] -pub struct Selection { - start_x: f32, - start_y: f32, - end_x: f32, - end_y: f32, -} - -impl fmt::Debug for Selection { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let rect = self.rect(); - f.debug_struct("Selection") - .field("x", &rect.x()) - .field("y", &rect.y()) - .field("width", &rect.width()) - .field("height", &rect.height()) - .finish() - } -} - -impl Selection { - pub fn new(start_x: f32, start_y: f32, end_x: f32, end_y: f32) -> Self { - Self { - start_x, - start_y, - end_x, - end_y, - } - } - - pub fn left_x(&self) -> f32 { - self.start_x.min(self.end_x) - } - - pub fn right_x(&self) -> f32 { - self.start_x.max(self.end_x) - } - - pub fn top_y(&self) -> f32 { - self.start_y.min(self.end_y) - } - - pub fn bottom_y(&self) -> f32 { - self.start_y.max(self.end_y) - } - - pub fn rect(&self) -> Rect { - Rect::new( - self.left_x(), - self.top_y(), - (self.start_x - self.end_x).abs(), - (self.start_y - self.end_y).abs(), - ) - } -} - -mod imp { - use super::*; - - #[derive(Debug, Default, glib::Properties)] - #[properties(wrapper_type = super::ViewPort)] - pub struct ViewPort { - #[property(get, set = Self::set_paintable, explicit_notify, nullable)] - pub(super) paintable: RefCell>, - #[property(get)] - pub(super) selection: Cell>, - - pub(super) paintable_rect: Cell>, - pub(super) selection_handles: Cell>, // [top-left, top-right, bottom-right, bottom-left] - - pub(super) drag_start: Cell>, - pub(super) drag_cursor: Cell, - - pub(super) pointer_position: Cell>, - - pub(super) handler_ids: RefCell>, - } - - #[glib::object_subclass] - impl ObjectSubclass for ViewPort { - const NAME: &'static str = "KoohaViewPort"; - type Type = super::ViewPort; - type ParentType = gtk::Widget; - } - - #[glib::derived_properties] - impl ObjectImpl for ViewPort { - fn constructed(&self) { - self.parent_constructed(); - - let obj = self.obj(); - - let motion_controller = gtk::EventControllerMotion::new(); - motion_controller.connect_enter(clone!(@weak obj => move |controller, x, y| { - obj.on_enter(controller, x, y); - })); - motion_controller.connect_motion(clone!(@weak obj => move |controller, x, y| { - obj.on_motion(controller, x, y); - })); - motion_controller.connect_leave(clone!(@weak obj => move |controller| { - obj.on_leave(controller); - })); - obj.add_controller(motion_controller); - - let gesture_drag = gtk::GestureDrag::builder().exclusive(true).build(); - gesture_drag.connect_drag_begin(clone!(@weak obj => move |controller, x, y| { - obj.on_drag_begin(controller, x, y); - })); - gesture_drag.connect_drag_update(clone!(@weak obj => move |controller, dx, dy| { - obj.on_drag_update(controller, dx, dy); - })); - gesture_drag.connect_drag_end(clone!(@weak obj => move |controller, dx, dy| { - obj.on_drag_end(controller, dx, dy); - })); - obj.add_controller(gesture_drag); - } - } - - impl WidgetImpl for ViewPort { - fn request_mode(&self) -> gtk::SizeRequestMode { - gtk::SizeRequestMode::HeightForWidth - } - - fn measure(&self, orientation: gtk::Orientation, for_size: i32) -> (i32, i32, i32, i32) { - if for_size == 0 { - return (0, 0, -1, -1); - } - - let Some(paintable) = self.obj().paintable() else { - return (0, 0, -1, -1); - }; - - if orientation == gtk::Orientation::Horizontal { - let (natural_width, _natural_height) = paintable.compute_concrete_size( - 0.0, - if for_size < 0 { 0.0 } else { for_size as f64 }, - DEFAULT_SIZE, - DEFAULT_SIZE, - ); - (0, natural_width.ceil() as i32, -1, -1) - } else { - let (_natural_width, natural_height) = paintable.compute_concrete_size( - if for_size < 0 { 0.0 } else { for_size as f64 }, - 0.0, - DEFAULT_SIZE, - DEFAULT_SIZE, - ); - (0, natural_height.ceil() as i32, -1, -1) - } - } - - fn snapshot(&self, snapshot: >k::Snapshot) { - let obj = self.obj(); - - if let Some(paintable) = obj.paintable() { - let widget_width = obj.width() as f64; - let widget_height = obj.height() as f64; - let widget_ratio = widget_width / widget_height; - - let paintable_width = paintable.intrinsic_width() as f64; - let paintable_height = paintable.intrinsic_height() as f64; - let paintable_ratio = paintable.intrinsic_aspect_ratio(); - - let (width, height) = - if widget_width >= paintable_width && widget_height >= paintable_height { - (paintable_width, paintable_height) - } else if paintable_ratio > widget_ratio { - (widget_width, widget_width / paintable_ratio) - } else { - (widget_height * paintable_ratio, widget_height) - }; - let x = (widget_width - width.ceil()) / 2.0; - let y = (widget_height - height.ceil()).floor() / 2.0; - - obj.imp().paintable_rect.set(Some(Rect::new( - x as f32, - y as f32, - width as f32, - height as f32, - ))); - - snapshot.save(); - snapshot.translate(&Point::new(x as f32, y as f32)); - paintable.snapshot(snapshot, width, height); - snapshot.restore(); - } - - if let Some(selection) = obj.selection() { - let selection_rect = selection.rect(); - - if let Some(paintable_rect) = obj.paintable_rect() { - let shade_color = gdk::RGBA::new(0.0, 0.0, 0.0, 0.5); - snapshot.append_color( - &shade_color, - &Rect::new( - paintable_rect.x(), - paintable_rect.y(), - selection.left_x() - paintable_rect.x(), - paintable_rect.height(), - ), - ); - snapshot.append_color( - &shade_color, - &Rect::new( - selection.right_x(), - paintable_rect.y(), - paintable_rect.width() + paintable_rect.x() - selection.right_x(), - paintable_rect.height(), - ), - ); - snapshot.append_color( - &shade_color, - &Rect::new( - selection.left_x(), - paintable_rect.y(), - selection_rect.width(), - selection.top_y() - paintable_rect.y(), - ), - ); - snapshot.append_color( - &shade_color, - &Rect::new( - selection.left_x(), - selection.bottom_y(), - selection_rect.width(), - paintable_rect.height() + paintable_rect.y() - selection.bottom_y(), - ) - .normalize_r(), - ); - } - - snapshot.append_border( - &RoundedRect::from_rect( - Rect::new( - selection_rect.x(), - selection_rect.y(), - selection_rect.width().max(1.0), - selection_rect.height().max(1.0), - ), - 0.0, - ), - &[SELECTION_LINE_WIDTH; 4], - &[SELECTION_COLOR; 4], - ); - - for handle in self.selection_handles.get().unwrap() { - let bounds = RoundedRect::from_rect(handle, SELECTION_HANDLE_RADIUS); - snapshot.append_outset_shadow( - &bounds, - &gdk::RGBA::new(0.0, 0.0, 0.0, 0.2), - 0.0, - 1.0, - 2.0, - 3.0, - ); - snapshot.push_rounded_clip(&bounds); - snapshot.append_color(&SELECTION_COLOR, &handle); - snapshot.pop(); - } - } - } - } - - impl ViewPort { - fn set_paintable(&self, paintable: Option) { - let obj = self.obj(); - - if paintable == obj.paintable() { - return; - } - - let _freeze_guard = obj.freeze_notify(); - - let mut handler_ids = self.handler_ids.borrow_mut(); - - if let Some(previous_paintable) = self.paintable.replace(paintable.clone()) { - for handler_id in handler_ids.drain(..) { - previous_paintable.disconnect(handler_id); - } - } - - if let Some(paintable) = paintable { - handler_ids.push(paintable.connect_invalidate_contents( - clone!(@weak obj => move |_| { - obj.queue_draw(); - }), - )); - handler_ids.push( - paintable.connect_invalidate_size(clone!(@weak obj => move |_| { - obj.queue_resize(); - })), - ); - } - - obj.queue_resize(); - obj.notify_paintable(); - } - } -} - -glib::wrapper! { - pub struct ViewPort(ObjectSubclass) - @extends gtk::Widget; -} - -impl ViewPort { - pub fn new() -> Self { - glib::Object::new() - } - - pub fn paintable_rect(&self) -> Option { - self.imp().paintable_rect.get() - } - - pub fn set_selection(&self, selection: Option) { - self.imp().selection.set(selection); - self.update_selection_handles(); - self.queue_draw(); - self.notify_selection(); - } - - fn on_enter(&self, _controller: >k::EventControllerMotion, x: f64, y: f64) { - let imp = self.imp(); - - imp.pointer_position - .set(Some(Point::new(x as f32, y as f32))); - } - - fn on_motion(&self, _controller: >k::EventControllerMotion, x: f64, y: f64) { - let imp = self.imp(); - - imp.pointer_position - .set(Some(Point::new(x as f32, y as f32))); - - if imp.drag_start.get().is_none() { - let cursor_type = self.compute_cursor_type(x as f32, y as f32); - self.set_cursor(cursor_type); - } - } - - fn on_leave(&self, _controller: >k::EventControllerMotion) { - let imp = self.imp(); - - imp.pointer_position.set(None); - - self.set_cursor(CursorType::Default); - } - - fn on_drag_begin(&self, _gesture: >k::GestureDrag, x: f64, y: f64) { - tracing::trace!("Drag begin at ({}, {})", x, y); - - let imp = self.imp(); - let cursor_type = self.compute_cursor_type(x as f32, y as f32); - - if cursor_type == CursorType::Crosshair { - imp.drag_cursor.set(CursorType::Crosshair); - self.set_cursor(CursorType::Crosshair); - - let paintable_rect = self.paintable_rect().unwrap(); - let x = (x as f32).clamp( - paintable_rect.x(), - paintable_rect.x() + paintable_rect.width(), - ); - let y = (y as f32).clamp( - paintable_rect.y(), - paintable_rect.y() + paintable_rect.height(), - ); - self.set_selection(Some(Selection { - start_x: x, - start_y: y, - end_x: x, - end_y: y, - })); - } else { - imp.drag_cursor.set(cursor_type); - imp.drag_start.set(Some(Point::new(x as f32, y as f32))); - - let selection = self.selection().unwrap(); - let mut new_selection = self.selection().unwrap(); - - if cursor_type == CursorType::Move { - new_selection.start_x = selection.left_x(); - new_selection.start_y = selection.top_y(); - new_selection.end_x = selection.right_x(); - new_selection.end_y = selection.bottom_y(); - } - if matches!( - cursor_type, - CursorType::NorthWestResize | CursorType::WestResize | CursorType::SouthWestResize - ) { - new_selection.start_x = selection.right_x(); - new_selection.end_x = selection.left_x(); - } - if matches!( - cursor_type, - CursorType::NorthEastResize | CursorType::EastResize | CursorType::SouthEastResize - ) { - new_selection.start_x = selection.left_x(); - new_selection.end_x = selection.right_x(); - } - if matches!( - cursor_type, - CursorType::NorthWestResize | CursorType::NorthResize | CursorType::NorthEastResize - ) { - new_selection.start_y = selection.bottom_y(); - new_selection.end_y = selection.top_y(); - } - if matches!( - cursor_type, - CursorType::SouthWestResize | CursorType::SouthResize | CursorType::SouthEastResize - ) { - new_selection.start_y = selection.top_y(); - new_selection.end_y = selection.bottom_y(); - } - - self.set_selection(Some(new_selection)); - } - } - - fn on_drag_update(&self, _gesture: >k::GestureDrag, _: f64, _: f64) { - let imp = self.imp(); - - let pointer_position = imp.pointer_position.get().unwrap(); - - let drag_cursor = imp.drag_cursor.get(); - - if drag_cursor == CursorType::Crosshair { - let Selection { - start_x, start_y, .. - } = self.selection().unwrap(); - let paintable_rect = self.paintable_rect().unwrap(); - self.set_selection(Some(Selection { - start_x, - start_y, - end_x: pointer_position.x().clamp( - paintable_rect.x(), - paintable_rect.width() + paintable_rect.x(), - ), - end_y: pointer_position.y().clamp( - paintable_rect.y(), - paintable_rect.height() + paintable_rect.y(), - ), - })); - } else { - let drag_start = imp.drag_start.get().unwrap(); - let mut dx = pointer_position.x() - drag_start.x(); - let mut dy = pointer_position.y() - drag_start.y(); - - if drag_cursor == CursorType::Move { - let Selection { - start_x, - start_y, - end_x, - end_y, - } = self.selection().unwrap(); - let mut new_start_x = start_x + dx; - let mut new_start_y = start_y + dy; - let mut new_end_x = end_x + dx; - let mut new_end_y = end_y + dy; - - let mut overshoot_x = 0.0; - let mut overshoot_y = 0.0; - - let paintable_rect = self.paintable_rect().unwrap(); - let selection_rect = self.selection().unwrap().rect(); - - // Keep the size intact if we bumped into the stage edge. - if new_start_x < paintable_rect.x() { - overshoot_x = paintable_rect.x() - new_start_x; - new_start_x = paintable_rect.x(); - new_end_x = new_start_x + selection_rect.width(); - } else if new_end_x > paintable_rect.width() + paintable_rect.x() { - overshoot_x = paintable_rect.width() + paintable_rect.x() - new_end_x; - new_end_x = paintable_rect.width() + paintable_rect.x(); - new_start_x = new_end_x - selection_rect.width(); - } - if new_start_y < paintable_rect.y() { - overshoot_y = paintable_rect.y() - new_start_y; - new_start_y = paintable_rect.y(); - new_end_y = new_start_y + selection_rect.height(); - } else if new_end_y > paintable_rect.height() + paintable_rect.y() { - overshoot_y = paintable_rect.height() + paintable_rect.y() - new_end_y; - new_end_y = paintable_rect.height() + paintable_rect.y(); - new_start_y = new_end_y - selection_rect.height(); - } - - dx += overshoot_x; - dy += overshoot_y; - - self.set_selection(Some(Selection { - start_x: new_start_x, - start_y: new_start_y, - end_x: new_end_x, - end_y: new_end_y, - })); - } else { - if matches!(drag_cursor, CursorType::WestResize | CursorType::EastResize) { - dy = 0.0; - } - if matches!( - drag_cursor, - CursorType::NorthResize | CursorType::SouthResize - ) { - dx = 0.0; - } - - let paintable_rect = self.paintable_rect().unwrap(); - let mut new_selection = self.selection().unwrap(); - - new_selection.end_x += dx; - if new_selection.end_x >= paintable_rect.width() + paintable_rect.x() { - dx -= new_selection.end_x - (paintable_rect.width() + paintable_rect.x()); - new_selection.end_x = paintable_rect.width() + paintable_rect.x(); - } else if new_selection.end_x < paintable_rect.x() { - dx -= new_selection.end_x - paintable_rect.x(); - new_selection.end_x = paintable_rect.x(); - } - - new_selection.end_y += dy; - if new_selection.end_y >= paintable_rect.height() + paintable_rect.y() { - dy -= new_selection.end_y - (paintable_rect.height() + paintable_rect.y()); - new_selection.end_y = paintable_rect.height() + paintable_rect.y(); - } else if new_selection.end_y < paintable_rect.y() { - dy -= new_selection.end_y - paintable_rect.y(); - new_selection.end_y = paintable_rect.y(); - } - - self.set_selection(Some(new_selection)); - let selection = new_selection; - - // If we drag the handle past a selection side, update which - // handles are which. - if selection.end_x > selection.start_x { - if drag_cursor == CursorType::NorthWestResize { - imp.drag_cursor.set(CursorType::NorthEastResize); - } else if drag_cursor == CursorType::SouthWestResize { - imp.drag_cursor.set(CursorType::SouthEastResize); - } else if drag_cursor == CursorType::WestResize { - imp.drag_cursor.set(CursorType::EastResize); - } - } else { - // Disable clippy error - if drag_cursor == CursorType::NorthEastResize { - imp.drag_cursor.set(CursorType::NorthWestResize); - } else if drag_cursor == CursorType::SouthEastResize { - imp.drag_cursor.set(CursorType::SouthWestResize); - } else if drag_cursor == CursorType::EastResize { - imp.drag_cursor.set(CursorType::WestResize); - } - } - - if selection.end_y > selection.start_y { - if drag_cursor == CursorType::NorthWestResize { - imp.drag_cursor.set(CursorType::SouthWestResize); - } else if drag_cursor == CursorType::NorthEastResize { - imp.drag_cursor.set(CursorType::SouthEastResize); - } else if drag_cursor == CursorType::NorthResize { - imp.drag_cursor.set(CursorType::SouthResize); - } - } else { - // Disable clippy error - if drag_cursor == CursorType::SouthWestResize { - imp.drag_cursor.set(CursorType::NorthWestResize); - } else if drag_cursor == CursorType::SouthEastResize { - imp.drag_cursor.set(CursorType::NorthEastResize); - } else if drag_cursor == CursorType::SouthResize { - imp.drag_cursor.set(CursorType::NorthResize); - } - } - - self.set_cursor(imp.drag_cursor.get()); - } - - imp.drag_start - .set(Some(Point::new(drag_start.x() + dx, drag_start.y() + dy))); - } - } - - fn on_drag_end(&self, _gesture: >k::GestureDrag, dx: f64, dy: f64) { - tracing::trace!("Drag end offset ({}, {})", dx, dy); - - let imp = self.imp(); - imp.drag_start.set(None); - - // The user clicked without dragging. Make up a larger selection - // to reduce confusion. - if let Some(mut selection) = self.selection() { - if imp.drag_cursor.get() == CursorType::Crosshair - && selection.end_x == selection.start_x - && selection.end_y == selection.start_y - { - let offset = 20.0 * self.scale_factor() as f32; - selection.start_x -= offset; - selection.start_y -= offset; - selection.end_x += offset; - selection.end_y += offset; - - let paintable_rect = self.paintable_rect().unwrap(); - let selection_rect = selection.rect(); - - // Keep the coordinates inside the stage. - if selection.start_x < paintable_rect.x() { - selection.start_x = paintable_rect.x(); - selection.end_x = selection.start_x + selection_rect.width(); - } else if selection.end_x > paintable_rect.width() + paintable_rect.x() { - selection.end_x = paintable_rect.width() + paintable_rect.x(); - selection.start_x = selection.end_x - selection_rect.width(); - } - if selection.start_y < paintable_rect.y() { - selection.start_y = paintable_rect.y(); - selection.end_y = selection.start_y + selection_rect.height(); - } else if selection.end_y > paintable_rect.height() + paintable_rect.y() { - selection.end_y = paintable_rect.height() + paintable_rect.y(); - selection.start_y = selection.end_y - selection_rect.height(); - } - - self.set_selection(Some(selection)); - } - } - - if let Some(pointer_position) = imp.pointer_position.get() { - let cursor_type = self.compute_cursor_type(pointer_position.x(), pointer_position.y()); - self.set_cursor(cursor_type); - } - } - - fn set_cursor(&self, cursor_type: CursorType) { - self.set_cursor_from_name(Some(cursor_type.name())); - } - - fn compute_cursor_type(&self, x: f32, y: f32) -> CursorType { - let imp = self.imp(); - - let point = Point::new(x, y); - - let Some(selection) = self.selection() else { - return CursorType::Crosshair; - }; - - let [top_left_handle, top_right_handle, bottom_right_handle, bottom_left_handle] = - imp.selection_handles.get().unwrap(); - - if top_left_handle.contains_point(&point) { - CursorType::NorthWestResize - } else if top_right_handle.contains_point(&point) { - CursorType::NorthEastResize - } else if bottom_right_handle.contains_point(&point) { - CursorType::SouthEastResize - } else if bottom_left_handle.contains_point(&point) { - CursorType::SouthWestResize - } else if selection.rect().contains_point(&point) { - CursorType::Move - } else if top_left_handle - .union(&top_right_handle) - .contains_point(&point) - { - CursorType::NorthResize - } else if top_right_handle - .union(&bottom_right_handle) - .contains_point(&point) - { - CursorType::EastResize - } else if bottom_right_handle - .union(&bottom_left_handle) - .contains_point(&point) - { - CursorType::SouthResize - } else if bottom_left_handle - .union(&top_left_handle) - .contains_point(&point) - { - CursorType::WestResize - } else { - CursorType::Crosshair - } - } - - fn update_selection_handles(&self) { - let imp = self.imp(); - - let Some(selection) = self.selection() else { - imp.selection_handles.set(None); - return; - }; - - let selection_handle_diameter = SELECTION_HANDLE_RADIUS * 2.0; - let top_left = Rect::new( - selection.left_x() - SELECTION_HANDLE_RADIUS, - selection.top_y() - SELECTION_HANDLE_RADIUS, - selection_handle_diameter, - selection_handle_diameter, - ); - let top_right = Rect::new( - selection.right_x() - SELECTION_HANDLE_RADIUS, - selection.top_y() - SELECTION_HANDLE_RADIUS, - selection_handle_diameter, - selection_handle_diameter, - ); - let bottom_right = Rect::new( - selection.right_x() - SELECTION_HANDLE_RADIUS, - selection.bottom_y() - SELECTION_HANDLE_RADIUS, - selection_handle_diameter, - selection_handle_diameter, - ); - let bottom_left = Rect::new( - selection.left_x() - SELECTION_HANDLE_RADIUS, - selection.bottom_y() - SELECTION_HANDLE_RADIUS, - selection_handle_diameter, - selection_handle_diameter, - ); - - imp.selection_handles - .set(Some([top_left, top_right, bottom_right, bottom_left])); - } -} - -impl Default for ViewPort { - fn default() -> Self { - Self::new() - } -} diff --git a/src/i18n.rs b/src/i18n.rs deleted file mode 100644 index f261bc60..00000000 --- a/src/i18n.rs +++ /dev/null @@ -1,85 +0,0 @@ -// Copied from Fractal GPLv3 -// See https://gitlab.gnome.org/GNOME/fractal/-/blob/c0bc4078bb2cdd511c89fdf41a51275db90bb7ab/src/i18n.rs - -use gettextrs::gettext; - -/// Like `gettext`, but replaces named variables using the given key-value tuples. -/// -/// The expected format to replace is `{name}`, where `name` is the first string -/// in a key-value tuple. -pub fn gettext_f(msgid: &str, args: &[(&str, &str)]) -> String { - let s = gettext(msgid); - freplace(s, args) -} - -/// Replace variables in the given string using the given key-value tuples. -/// -/// The expected format to replace is `{name}`, where `name` is the first string -/// in a key-value tuple. -fn freplace(s: String, args: &[(&str, &str)]) -> String { - // This function is useless if there are no arguments - debug_assert!(!args.is_empty(), "atleast one key-value pair must be given"); - - // We could check here if all keys were used, but some translations might - // not use all variables, so we don't do that. - - let mut s = s; - for (key, val) in args { - s = s.replace(&format!("{{{key}}}"), val); - } - - debug_assert!(!s.contains('{'), "all format variables must be replaced"); - - if tracing::enabled!(tracing::Level::WARN) && s.contains('{') { - tracing::warn!( - "all format variables must be replaced, but some were not: {}", - s - ); - } - - s -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - #[should_panic = "atleast one key-value pair must be given"] - fn freplace_no_args() { - gettext_f("no args", &[]); - } - - #[test] - #[should_panic = "all format variables must be replaced"] - fn freplace_missing_key() { - gettext_f("missing {one}", &[("two", "2")]); - } - - #[test] - fn gettext_f_simple() { - assert_eq!(gettext_f("no replace", &[("one", "1")]), "no replace"); - assert_eq!(gettext_f("{one} param", &[("one", "1")]), "1 param"); - assert_eq!( - gettext_f("middle {one} param", &[("one", "1")]), - "middle 1 param" - ); - assert_eq!(gettext_f("end {one}", &[("one", "1")]), "end 1"); - } - - #[test] - fn gettext_f_multiple() { - assert_eq!( - gettext_f("multiple {one} and {two}", &[("one", "1"), ("two", "2")]), - "multiple 1 and 2" - ); - assert_eq!( - gettext_f("multiple {two} and {one}", &[("one", "1"), ("two", "2")]), - "multiple 2 and 1" - ); - assert_eq!( - gettext_f("multiple {one} and {one}", &[("one", "1"), ("two", "2")]), - "multiple 1 and 1" - ); - } -} diff --git a/src/main.rs b/src/main.rs index fb43bcbd..289ddb79 100644 --- a/src/main.rs +++ b/src/main.rs @@ -25,25 +25,19 @@ mod about; mod application; -mod area_selector; mod audio_device; mod cancelled; mod config; mod element_properties; mod help; -mod i18n; -mod pipe; mod pipeline; mod preferences_window; mod profile; -mod recording; mod screencast_session; mod settings; -mod timer; mod toggle_button; mod utils; mod view_port; -mod win; mod window; use gettextrs::{gettext, LocaleCategory}; diff --git a/src/pipe.rs b/src/pipe.rs deleted file mode 100644 index 403e2c19..00000000 --- a/src/pipe.rs +++ /dev/null @@ -1,431 +0,0 @@ -use anyhow::{ensure, Context, Ok, Result}; -use gst::prelude::*; -use gtk::{glib, graphene::Rect}; - -use std::{ - ffi::OsStr, - os::unix::io::RawFd, - path::{Path, PathBuf}, -}; - -use crate::{ - area_selector::Data as SelectAreaData, profile::Profile, screencast_session::Stream, utils, -}; - -// TODO -// * Do we need restrictions? -// * Can we drop filter elements (videorate, videoconvert, videoscale, audioconvert) and let encodebin handle it? -// * Can we set frame rate directly on profile format? -// * Add tests - -const DEFAULT_AUDIO_SAMPLE_RATE: i32 = 48_000; - -#[derive(Debug)] -#[must_use] -pub struct PipelineBuilder { - saving_location: PathBuf, - framerate: u32, - profile: Box, - fd: RawFd, - streams: Vec, - speaker_source: Option, - mic_source: Option, - select_area_data: Option, -} - -impl PipelineBuilder { - pub fn new( - saving_location: &Path, - framerate: u32, - profile: Box, - fd: RawFd, - streams: Vec, - ) -> Self { - Self { - saving_location: saving_location.to_path_buf(), - framerate, - profile, - fd, - streams, - speaker_source: None, - mic_source: None, - select_area_data: None, - } - } - - pub fn speaker_source(&mut self, speaker_source: String) -> &mut Self { - self.speaker_source = Some(speaker_source); - self - } - - pub fn mic_source(&mut self, mic_source: String) -> &mut Self { - self.mic_source = Some(mic_source); - self - } - - pub fn select_area_data(&mut self, data: SelectAreaData) -> &mut Self { - self.select_area_data = Some(data); - self - } - - pub fn build(&self) -> Result { - let file_path = new_recording_path(&self.saving_location, self.profile.file_extension()); - - let queue = gst::ElementFactory::make("queue") - .name("sinkqueue") - .build()?; - let filesink = gst::ElementFactory::make("filesink") - .name("filesink") - .property( - "location", - file_path - .to_str() - .context("Could not convert file path to string")?, - ) - .build()?; - - let pipeline = gst::Pipeline::new(); - pipeline.add_many([&queue, &filesink])?; - queue.link(&filesink)?; - - tracing::debug!( - file_path = %file_path.display(), - framerate = self.framerate, - profile = ?self.profile, - stream_len = self.streams.len(), - streams = ?self.streams, - speaker_source = ?self.speaker_source, - mic_source = ?self.mic_source, - select_area_data = ?self.select_area_data, - ); - - ensure!(!self.streams.is_empty(), "No streams provided"); - - let videosrc_bin = pipewiresrc_bin( - self.fd, - &self.streams, - self.framerate, - self.select_area_data.as_ref(), - ) - .context("Failed to create videosrc bin")?; - - pipeline.add(&videosrc_bin)?; - - let audiosrc_bin = if self.profile.supports_audio() - && (self.speaker_source.is_some() || self.mic_source.is_some()) - { - let audiosrc_bin = pulsesrc_bin( - [&self.speaker_source, &self.mic_source] - .into_iter() - .filter_map(|s| s.as_deref()), - ) - .context("Failed to create audiosrc bin")?; - pipeline.add(&audiosrc_bin)?; - - Some(audiosrc_bin) - } else { - if self.speaker_source.is_some() || self.mic_source.is_some() { - tracing::warn!( - "Selected profiles does not support audio, but audio sources are provided. Ignoring" - ); - } - - None - }; - - self.profile - .attach( - &pipeline, - videosrc_bin.upcast_ref(), - audiosrc_bin.as_ref().map(|a| a.upcast_ref()), - &queue, - ) - .context("Failed to attach profile to pipeline")?; - - Ok(pipeline) - } -} - -fn pipewiresrc_with_default(fd: RawFd, path: &str) -> Result { - // Workaround copied from https://gitlab.gnome.org/GNOME/gnome-shell/-/commit/d32c03488fcf6cdb0ca2e99b0ed6ade078460deb - let registry = gst::Registry::get(); - let needs_copy = registry.check_feature_version("pipewiresrc", 0, 3, 57) - && !registry.check_feature_version("videoconvert", 1, 20, 4); - - tracing::debug!("pipewiresrc needs copy: {}", needs_copy); - - let src = gst::ElementFactory::make("pipewiresrc") - .property("fd", fd) - .property("path", path) - .property("do-timestamp", true) - .property("keepalive-time", 1000) - .property("resend-last", true) - .property("always-copy", needs_copy) - .build()?; - - Ok(src) -} - -fn videoconvert_with_default() -> Result { - let conv = gst::ElementFactory::make("videoconvert") - .property("chroma-mode", gst_video::VideoChromaMode::None) - .property("dither", gst_video::VideoDitherMethod::None) - .property("matrix-mode", gst_video::VideoMatrixMode::OutputOnly) - .property("n-threads", utils::ideal_thread_count()) - .build()?; - Ok(conv) -} - -/// Create a videocrop element that computes the crop from the given coordinates -/// and size. -fn videocrop_compute(data: &SelectAreaData) -> Result { - let SelectAreaData { - selection, - paintable_rect, - stream_size, - } = data; - - let (stream_width, stream_height) = stream_size; - let scale_factor_h = *stream_width as f32 / paintable_rect.width(); - let scale_factor_v = *stream_height as f32 / paintable_rect.height(); - - if scale_factor_h != scale_factor_v { - tracing::warn!( - scale_factor_h, - scale_factor_v, - "Scale factors of horizontal and vertical are unequal" - ); - } - - // Both paintable and selection position are relative to the widget coordinates. - // To get the absolute position and so correct crop values, subtract the paintable - // rect's position from the selection rect. - let old_selection_rect = selection.rect(); - let selection_rect_scaled = Rect::new( - old_selection_rect.x() - paintable_rect.x(), - old_selection_rect.y() - paintable_rect.y(), - old_selection_rect.width(), - old_selection_rect.height(), - ) - .scale(scale_factor_h, scale_factor_v); - - let raw_top_crop = selection_rect_scaled.y(); - let raw_left_crop = selection_rect_scaled.x(); - let raw_right_crop = - *stream_width as f32 - (selection_rect_scaled.width() + selection_rect_scaled.x()); - let raw_bottom_crop = - *stream_height as f32 - (selection_rect_scaled.height() + selection_rect_scaled.y()); - - tracing::debug!(raw_top_crop, raw_left_crop, raw_right_crop, raw_bottom_crop); - - let top_crop = round_to_even_f32(raw_top_crop).clamp(0, *stream_height); - let left_crop = round_to_even_f32(raw_left_crop).clamp(0, *stream_width); - let right_crop = round_to_even_f32(raw_right_crop).clamp(0, *stream_width); - let bottom_crop = round_to_even_f32(raw_bottom_crop).clamp(0, *stream_height); - - tracing::debug!(top_crop, left_crop, right_crop, bottom_crop); - - // x264enc requires even resolution. - let crop = gst::ElementFactory::make("videocrop") - .property("top", top_crop) - .property("left", left_crop) - .property("right", right_crop) - .property("bottom", bottom_crop) - .build()?; - Ok(crop) -} - -/// Creates a bin with a src pad for multiple pipewire streams. -/// (If has select area data) -/// pipewiresrc1 -> videorate -> | | | -/// | V V -/// pipewiresrc2 -> videorate -> | -> compositor -> videoconvert -> videoscale -> videocrop -> queue -/// | -/// pipewiresrcn -> videorate -> | -pub fn pipewiresrc_bin( - fd: RawFd, - streams: &[Stream], - framerate: u32, - select_area_data: Option<&SelectAreaData>, -) -> Result { - let bin = gst::Bin::new(); - - let compositor = gst::ElementFactory::make("compositor").build()?; - let videoconvert = videoconvert_with_default()?; - let queue = gst::ElementFactory::make("queue").build()?; - - bin.add_many([&compositor, &videoconvert, &queue])?; - compositor.link(&videoconvert)?; - - if let Some(data) = select_area_data { - let videoscale = gst::ElementFactory::make("videoscale").build()?; - let videocrop = videocrop_compute(data)?; - - // x264enc requires even resolution. - let (stream_width, stream_height) = data.stream_size; - let videoscale_filter = gst::Caps::builder("video/x-raw") - .field("width", round_to_even(stream_width)) - .field("height", round_to_even(stream_height)) - .build(); - - bin.add_many([&videoscale, &videocrop])?; - videoconvert.link(&videoscale)?; - videoscale.link_filtered(&videocrop, &videoscale_filter)?; - videocrop.link(&queue)?; - } else { - videoconvert.link(&queue)?; - } - - let videorate_filter = gst::Caps::builder("video/x-raw") - .field("framerate", gst::Fraction::new(framerate as i32, 1)) - .build(); - - let mut last_pos = 0; - for stream in streams { - let pipewiresrc = pipewiresrc_with_default(fd, &stream.node_id().to_string())?; - let videorate = gst::ElementFactory::make("videorate").build()?; - let videorate_capsfilter = gst::ElementFactory::make("capsfilter") - .property("caps", &videorate_filter) - .build()?; - - bin.add_many([&pipewiresrc, &videorate, &videorate_capsfilter])?; - gst::Element::link_many([&pipewiresrc, &videorate, &videorate_capsfilter])?; - - let compositor_sink_pad = compositor - .request_pad_simple("sink_%u") - .context("Failed to request sink_%u pad from compositor")?; - compositor_sink_pad.set_property("xpos", last_pos); - videorate_capsfilter - .static_pad("src") - .unwrap() - .link(&compositor_sink_pad)?; - - let stream_width = stream.size().unwrap().0; - last_pos += stream_width; - } - - let queue_pad = queue.static_pad("src").unwrap(); - bin.add_pad( - &gst::GhostPad::builder_with_target(&queue_pad)? - .name("src") - .build(), - )?; - - Ok(bin) -} - -/// Creates a bin with a src pad for a pulse audio device -/// -/// pulsesrc1 -> audioresample -> | -/// | -/// pulsesrc2 -> audioresample -> | -> audiomixer -> audiorate -> audioconvert -> queue -/// | -/// pulsesrcn -> audioresample -> | -fn pulsesrc_bin<'a>(device_names: impl IntoIterator) -> Result { - let bin = gst::Bin::new(); - - let audiomixer = gst::ElementFactory::make("audiomixer").build()?; - let audiorate = gst::ElementFactory::make("audiorate").build()?; - let audioconvert = gst::ElementFactory::make("audioconvert").build()?; - let queue = gst::ElementFactory::make("queue").build()?; - - let sample_rate_filter = gst::Caps::builder("audio/x-raw") - .field("rate", DEFAULT_AUDIO_SAMPLE_RATE) - .build(); - - bin.add_many([&audiomixer, &audiorate, &audioconvert, &queue])?; - audiomixer.link_filtered(&audiorate, &sample_rate_filter)?; - gst::Element::link_many([&audiorate, &audioconvert, &queue])?; - - for device_name in device_names { - let pulsesrc = gst::ElementFactory::make("pulsesrc") - .property("device", device_name) - .property("provide-clock", false) - .build()?; - let audioresample = gst::ElementFactory::make("audioresample").build()?; - let capsfilter = gst::ElementFactory::make("capsfilter") - .property("caps", &sample_rate_filter) - .build()?; - - bin.add_many([&pulsesrc, &audioresample, &capsfilter])?; - gst::Element::link_many([&pulsesrc, &audioresample, &capsfilter])?; - - let audiomixer_sink_pad = audiomixer - .request_pad_simple("sink_%u") - .context("Failed to request sink_%u pad from audiomixer")?; - capsfilter - .static_pad("src") - .unwrap() - .link(&audiomixer_sink_pad)?; - } - - let queue_pad = queue.static_pad("src").unwrap(); - bin.add_pad( - &gst::GhostPad::builder_with_target(&queue_pad)? - .name("src") - .build(), - )?; - - Ok(bin) -} - -fn round_to_even(number: i32) -> i32 { - number / 2 * 2 -} - -fn round_to_even_f32(number: f32) -> i32 { - (number / 2.0).round() as i32 * 2 -} - -fn new_recording_path(saving_location: &Path, extension: impl AsRef) -> PathBuf { - let file_name = glib::DateTime::now_local() - .expect("You are somehow on year 9999") - .format("Kooha-%F-%H-%M-%S") - .expect("Invalid format string"); - - let mut path = saving_location.join(file_name); - path.set_extension(extension); - - path -} - -#[cfg(test)] -mod test { - use super::*; - - macro_rules! assert_even { - ($number:expr) => { - assert_eq!($number % 2, 0) - }; - } - - #[test] - fn odd_round_to_even() { - assert_even!(round_to_even(5)); - assert_even!(round_to_even(101)); - } - - #[test] - fn odd_round_to_even_f32() { - assert_even!(round_to_even_f32(3.0)); - assert_even!(round_to_even_f32(99.0)); - } - - #[test] - fn even_round_to_even() { - assert_even!(round_to_even(50)); - assert_even!(round_to_even(4)); - } - - #[test] - fn even_round_to_even_f32() { - assert_even!(round_to_even_f32(300.0)); - assert_even!(round_to_even_f32(6.0)); - } - - #[test] - fn float_round_to_even_f32() { - assert_even!(round_to_even_f32(5.3)); - assert_even!(round_to_even_f32(2.9)); - } -} diff --git a/src/recording.rs b/src/recording.rs deleted file mode 100644 index 2bf46aeb..00000000 --- a/src/recording.rs +++ /dev/null @@ -1,581 +0,0 @@ -use anyhow::{ensure, Context, Error, Result}; -use gettextrs::gettext; -use gst::prelude::*; -use gtk::{ - gio::{self, prelude::*}, - glib::{self, clone, closure_local, subclass::prelude::*}, -}; - -use std::{ - cell::{Cell, OnceCell, RefCell}, - error, fmt, - os::unix::prelude::RawFd, - rc::Rc, - time::Duration, -}; - -use crate::{ - area_selector::AreaSelector, - audio_device::{self, Class as AudioDeviceClass}, - cancelled::Cancelled, - help::{ErrorExt, ResultExt}, - i18n::gettext_f, - pipe::PipelineBuilder, - screencast_session::{CursorMode, PersistMode, ScreencastSession, SourceType, Stream}, - settings::{CaptureMode, Settings}, - timer::Timer, - utils, -}; - -const DEFAULT_DURATION_UPDATE_INTERVAL: Duration = Duration::from_millis(200); - -#[derive(Debug)] -pub struct NoProfileError; - -impl fmt::Display for NoProfileError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str(&gettext("No active profile")) - } -} - -impl error::Error for NoProfileError {} - -#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, glib::Boxed)] -#[boxed_type(name = "KoohaRecordingState")] -pub enum State { - #[default] - Init, - Delayed { - secs_left: u64, - }, - Recording, - Paused, - Flushing, - Finished, -} - -#[derive(Debug, Clone, glib::SharedBoxed)] -#[shared_boxed_type(name = "KoohaRecordingResult")] -struct BoxedResult(Rc>); - -mod imp { - use super::*; - use glib::{once_cell::sync::Lazy, subclass::Signal}; - use gst::bus::BusWatchGuard; - - #[derive(Debug, Default, glib::Properties)] - #[properties(wrapper_type = super::Recording)] - pub struct Recording { - #[property(get)] - pub(super) state: Cell, - #[property(get)] - pub(super) duration: Cell, - - pub(super) file: OnceCell, - - pub(super) timer: RefCell>, - pub(super) session: RefCell>, - pub(super) duration_source_id: RefCell>, - pub(super) pipeline: OnceCell, - pub(super) bus_watch_guard: RefCell>, - } - - #[glib::object_subclass] - impl ObjectSubclass for Recording { - const NAME: &'static str = "KoohaRecording"; - type Type = super::Recording; - } - - #[glib::derived_properties] - impl ObjectImpl for Recording { - fn dispose(&self) { - if let Some(timer) = self.timer.take() { - timer.cancel(); - } - - if let Some(pipeline) = self.pipeline.get() { - if let Err(err) = pipeline.set_state(gst::State::Null) { - tracing::warn!("Failed to stop pipeline on dispose: {:?}", err); - } - } - - self.obj().close_session(); - - if let Some(source_id) = self.duration_source_id.take() { - source_id.remove(); - } - } - - fn signals() -> &'static [glib::subclass::Signal] { - static SIGNALS: Lazy> = Lazy::new(|| { - vec![Signal::builder("finished") - .param_types([BoxedResult::static_type()]) - .build()] - }); - - SIGNALS.as_ref() - } - } -} - -glib::wrapper! { - pub struct Recording(ObjectSubclass); -} - -impl Recording { - pub fn new() -> Self { - glib::Object::new() - } - - pub async fn start(&self, parent: Option<&impl IsA>, settings: &Settings) { - if !matches!(self.state(), State::Init) { - tracing::error!("Trying to start recording on a non-init state"); - return; - } - - if let Err(err) = self.start_inner(parent, settings).await { - self.close_session(); - self.set_finished(Err(err)); - } - } - - async fn start_inner( - &self, - parent: Option<&impl IsA>, - settings: &Settings, - ) -> Result<()> { - let imp = self.imp(); - let profile = settings.profile().context(NoProfileError)?; - let profile_supports_audio = profile.supports_audio(); - - // setup screencast session - let restore_token = settings.screencast_restore_token(); - settings.set_screencast_restore_token(""); - let (screencast_session, streams, restore_token, fd) = new_screencast_session( - if settings.show_pointer() { - CursorMode::EMBEDDED - } else { - CursorMode::HIDDEN - }, - if utils::is_experimental_mode() { - SourceType::MONITOR | SourceType::WINDOW - } else { - SourceType::MONITOR - }, - true, - Some(&restore_token), - PersistMode::ExplicitlyRevoked, - parent, - ) - .await - .with_help( - || { - gettext_f( - // Translators: Do NOT translate the contents between '{' and '}', this is a variable name. - "Check out {link} for help.", - &[("link", r#"It Doesn't Work page"#)], - ) - }, - || gettext("Failed to start recording"), - )?; - imp.session.replace(Some(screencast_session)); - settings.set_screencast_restore_token(&restore_token.unwrap_or_default()); - - let mut pipeline_builder = PipelineBuilder::new( - &settings.saving_location(), - settings.video_framerate(), - profile, - fd, - streams.clone(), - ); - - // select area - if settings.capture_mode() == CaptureMode::Selection { - let data = - AreaSelector::present(Some(&utils::app_instance().window()), fd, &streams).await?; - pipeline_builder.select_area_data(data); - } - - // setup timer - let timer = Timer::new( - settings.record_delay(), - clone!(@weak self as obj => move |secs_left| { - obj.set_state(State::Delayed { - secs_left - }); - }), - ); - imp.timer.replace(Some(Timer::clone(&timer))); - timer.await?; - - // setup audio sources - if profile_supports_audio { - if settings.record_mic() { - pipeline_builder.mic_source( - audio_device::find_default_name(AudioDeviceClass::Source) - .await - .with_context(|| gettext("No microphone source found"))?, - ); - } - if settings.record_speaker() { - pipeline_builder.speaker_source( - audio_device::find_default_name(AudioDeviceClass::Sink) - .await - .with_context(|| gettext("No desktop speaker source found"))?, - ); - } - } - - // build pipeline - let pipeline = pipeline_builder.build().with_help( - || gettext("A GStreamer plugin may not be installed."), - || gettext("Failed to start recording"), - )?; - imp.pipeline.set(pipeline.clone()).unwrap(); - let location = pipeline - .by_name("filesink") - .context("Element filesink not found on pipeline")? - .property::("location"); - imp.file.set(gio::File::for_path(location)).unwrap(); - let bus_watch_guard = pipeline - .bus() - .unwrap() - .add_watch_local( - clone!(@weak self as obj => @default-return glib::ControlFlow::Break, move |_, message| { - obj.handle_bus_message(message) - }), - ) - .unwrap(); - imp.bus_watch_guard.replace(Some(bus_watch_guard)); - imp.duration_source_id.replace(Some(glib::timeout_add_local( - DEFAULT_DURATION_UPDATE_INTERVAL, - clone!(@weak self as obj => @default-return glib::ControlFlow::Break, move || { - obj.update_duration(); - glib::ControlFlow::Continue - }), - ))); - pipeline - .set_state(gst::State::Playing) - .context("Failed to initialize pipeline state to playing") - .with_help( - || gettext("Make sure that the saving location exists and is accessible."), - || gettext("Failed to start recording"), - )?; - self.update_duration(); - - Ok(()) - } - - pub fn pause(&self) -> Result<()> { - ensure!( - matches!(self.state(), State::Recording), - "Recording can only be paused from recording state" - ); - - self.pipeline() - .set_state(gst::State::Paused) - .context("Failed to set pipeline state to paused")?; - - Ok(()) - } - - pub fn resume(&self) -> Result<()> { - ensure!( - matches!(self.state(), State::Paused), - "Recording can only be resumed from paused state" - ); - - self.pipeline() - .set_state(gst::State::Playing) - .context("Failed to set pipeline state to playing from paused")?; - - Ok(()) - } - - pub fn stop(&self) { - let state = self.state(); - - if matches!(state, State::Init | State::Flushing | State::Finished) { - tracing::error!("Trying to stop recording on a `{:?}` state", state); - return; - } - - self.set_state(State::Flushing); - - tracing::debug!("Sending eos event to pipeline"); - // FIXME Maybe it is needed to verify if we received the same - // eos event by checking its seqnum in the bus? - self.pipeline().send_event(gst::event::Eos::new()); - } - - pub fn cancel(&self) { - let imp = self.imp(); - - tracing::debug!("Cancelling recording"); - - if let Some(timer) = imp.timer.take() { - timer.cancel(); - } - - if let Some(pipeline) = imp.pipeline.get() { - if let Err(err) = pipeline.set_state(gst::State::Null) { - tracing::warn!("Failed to stop pipeline on cancel: {:?}", err); - } - } - - let _ = imp.bus_watch_guard.take(); - - self.close_session(); - - if let Some(source_id) = imp.duration_source_id.take() { - source_id.remove(); - } - - // HACK we need to return before calling this to avoid a `BorrowMutError` when - // `Window` tried to take the `recording` on finished callback while `recording` - // is borrowed to call `cancel`. - glib::idle_add_local_once(clone!(@weak self as obj => move || { - obj.set_finished(Err(Error::from(Cancelled::new("recording")))); - })); - - self.file().delete_async( - glib::Priority::DEFAULT_IDLE, - gio::Cancellable::NONE, - |res| { - if let Err(err) = res { - tracing::warn!("Failed to delete recording file: {:?}", err); - } - }, - ); - } - - pub fn connect_finished(&self, f: F) -> glib::SignalHandlerId - where - F: Fn(&Self, &Result) + 'static, - { - self.connect_closure( - "finished", - true, - closure_local!(|obj: &Self, result: BoxedResult| { - f(obj, &result.0); - }), - ) - } - - fn set_state(&self, state: State) { - if state == self.state() { - return; - } - - self.imp().state.replace(state); - self.notify_state(); - } - - fn file(&self) -> &gio::File { - self.imp() - .file - .get() - .expect("file not set, make sure to start recording first") - } - - fn pipeline(&self) -> &gst::Pipeline { - self.imp() - .pipeline - .get() - .expect("pipeline not set, make sure to start recording first") - } - - fn set_finished(&self, res: Result) { - self.set_state(State::Finished); - - let result = BoxedResult(Rc::new(res)); - self.emit_by_name::<()>("finished", &[&result]); - } - - /// Closes session on the background - fn close_session(&self) { - if let Some(session) = self.imp().session.take() { - glib::spawn_future_local(async move { - if let Err(err) = session.close().await { - tracing::warn!("Failed to close screencast session: {:?}", err); - } - }); - } - } - - fn update_duration(&self) { - let clock_time = self - .imp() - .pipeline - .get() - .and_then(|pipeline| pipeline.query_position::()) - .unwrap_or(gst::ClockTime::ZERO); - - if clock_time == self.duration() { - return; - } - - self.imp().duration.set(clock_time); - self.notify_duration(); - } - - fn handle_bus_message(&self, message: &gst::Message) -> glib::ControlFlow { - use gst::MessageView; - - let imp = self.imp(); - - match message.view() { - MessageView::Error(e) => { - tracing::debug!(state = ?self.state(), "Received error at bus"); - - if let Err(err) = self.pipeline().set_state(gst::State::Null) { - tracing::warn!("Failed to stop pipeline on error: {:?}", err); - } - - self.close_session(); - - if let Some(source_id) = imp.duration_source_id.take() { - source_id.remove(); - } - - // TODO print error quarks for all glib::Error - - let error = Error::from(e.error()) - .context(e.debug().unwrap_or_else(|| "".into())) - .context(gettext("An error occurred while recording")); - - let error = if e.error().matches(gst::ResourceError::OpenWrite) { - error.help( - gettext("Make sure that the saving location exists and is accessible."), - gettext_f( - // Translators: Do NOT translate the contents between '{' and '}', this is a variable name. - "Failed to open “{path}” for writing", - &[("path", &self.file().uri())], - ), - ) - } else { - error - }; - - self.set_finished(Err(error)); - - glib::ControlFlow::Break - } - MessageView::Eos(..) => { - tracing::debug!("Eos signal received from record bus"); - - if self.state() != State::Flushing { - tracing::error!("Received an Eos signal on a {:?} state", self.state()); - } - - if let Err(err) = self.pipeline().set_state(gst::State::Null) { - tracing::error!("Failed to stop pipeline on eos: {:?}", err); - } - - self.close_session(); - - if let Some(source_id) = imp.duration_source_id.take() { - source_id.remove(); - } - - self.set_finished(Ok(self.file().clone())); - - glib::ControlFlow::Break - } - MessageView::StateChanged(sc) => { - let new_state = sc.current(); - - if message.src() - != imp - .pipeline - .get() - .map(|pipeline| pipeline.upcast_ref::()) - { - tracing::trace!( - "`{}` changed state from `{:?}` -> `{:?}`", - message - .src() - .map_or_else(|| "".into(), |e| e.name()), - sc.old(), - new_state, - ); - return glib::ControlFlow::Continue; - } - - tracing::debug!( - "Pipeline changed state from `{:?}` -> `{:?}`", - sc.old(), - new_state, - ); - - let state = match new_state { - gst::State::Paused => State::Paused, - gst::State::Playing => State::Recording, - _ => return glib::ControlFlow::Continue, - }; - self.set_state(state); - - glib::ControlFlow::Continue - } - MessageView::Warning(w) => { - tracing::warn!("Received warning message on bus: {:?}", w); - glib::ControlFlow::Continue - } - MessageView::Info(i) => { - tracing::debug!("Received info message on bus: {:?}", i); - glib::ControlFlow::Continue - } - other => { - tracing::trace!("Received other message on bus: {:?}", other); - glib::ControlFlow::Continue - } - } - } -} - -impl Default for Recording { - fn default() -> Self { - Self::new() - } -} - -async fn new_screencast_session( - cursor_mode: CursorMode, - source_type: SourceType, - is_multiple_sources: bool, - restore_token: Option<&str>, - persist_mode: PersistMode, - parent_window: Option<&impl IsA>, -) -> Result<(ScreencastSession, Vec, Option, RawFd)> { - let screencast_session = ScreencastSession::new() - .await - .context("Failed to create ScreencastSession")?; - - tracing::debug!( - "ScreenCast portal version: {:?}", - screencast_session.version() - ); - tracing::debug!( - "Available cursor modes: {:?}", - screencast_session.available_cursor_modes() - ); - tracing::debug!( - "Available source types: {:?}", - screencast_session.available_source_types() - ); - - // TODO handle Closed signal from service side - let (streams, restore_token, fd) = screencast_session - .begin( - cursor_mode, - source_type, - is_multiple_sources, - restore_token, - persist_mode, - parent_window, - ) - .await - .context("Failed to begin ScreencastSession")?; - - Ok((screencast_session, streams, restore_token, fd)) -} diff --git a/src/timer.rs b/src/timer.rs deleted file mode 100644 index 2afbe8ff..00000000 --- a/src/timer.rs +++ /dev/null @@ -1,223 +0,0 @@ -use futures_util::future::FusedFuture; -use gtk::glib::{self, clone}; - -use std::{ - cell::{Cell, RefCell}, - fmt, - future::Future, - pin::Pin, - rc::Rc, - task::{Context, Poll, Waker}, - time::{Duration, Instant}, -}; - -use crate::cancelled::Cancelled; - -const DEFAULT_SECS_LEFT_UPDATE_INTERVAL: Duration = Duration::from_millis(200); - -/// Reference counted cancellable timer future -#[derive(Clone)] -pub struct Timer { - inner: Rc, -} - -impl fmt::Debug for Timer { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("Timer") - .field("duration", &self.inner.duration) - .field("state", &self.inner.state.get()) - .field("elapsed", &self.inner.instant.get().map(|i| i.elapsed())) - .finish() - } -} - -#[derive(Debug, Clone, Copy)] -enum State { - Waiting, - Cancelled, - Done, -} - -impl State { - fn to_poll(self) -> Poll<::Output> { - match self { - State::Waiting => Poll::Pending, - State::Cancelled => Poll::Ready(Err(Cancelled::new("timer"))), - State::Done => Poll::Ready(Ok(())), - } - } -} - -struct Inner { - duration: Duration, - - secs_left_changed_cb: Box, - secs_left_changed_source_id: RefCell>, - - state: Cell, - - instant: Cell>, - waker: RefCell>, - source_id: RefCell>, -} - -impl Inner { - fn secs_left(&self) -> u64 { - if self.is_terminated() { - return 0; - } - - let elapsed_secs = self - .instant - .get() - .map_or(Duration::ZERO, |instant| instant.elapsed()) - .as_secs(); - - self.duration.as_secs() - elapsed_secs - } - - fn is_terminated(&self) -> bool { - matches!(self.state.get(), State::Done | State::Cancelled) - } -} - -impl Timer { - /// The timer will start as soon as it gets polled - pub fn new(duration: Duration, secs_left_changed_cb: impl Fn(u64) + 'static) -> Self { - Self { - inner: Rc::new(Inner { - duration, - secs_left_changed_cb: Box::new(secs_left_changed_cb), - secs_left_changed_source_id: RefCell::new(None), - state: Cell::new(State::Waiting), - instant: Cell::new(None), - waker: RefCell::new(None), - source_id: RefCell::new(None), - }), - } - } - - pub fn cancel(&self) { - if self.inner.is_terminated() { - return; - } - - self.inner.state.set(State::Cancelled); - - if let Some(source_id) = self.inner.source_id.take() { - source_id.remove(); - } - - if let Some(source_id) = self.inner.secs_left_changed_source_id.take() { - source_id.remove(); - } - - if let Some(waker) = self.inner.waker.take() { - waker.wake(); - } - } -} - -impl Future for Timer { - type Output = Result<(), Cancelled>; - - fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { - match self.inner.state.get().to_poll() { - ready @ Poll::Ready(_) => return ready, - Poll::Pending => {} - } - - if self.inner.duration == Duration::ZERO { - self.inner.state.set(State::Done); - return Poll::Ready(Ok(())); - } - - let waker = cx.waker().clone(); - self.inner.waker.replace(Some(waker)); - - self.inner - .secs_left_changed_source_id - .replace(Some(glib::timeout_add_local( - DEFAULT_SECS_LEFT_UPDATE_INTERVAL, - clone!(@weak self.inner as inner => @default-return glib::ControlFlow::Break, move || { - (inner.secs_left_changed_cb)(inner.secs_left()); - glib::ControlFlow::Continue - }), - ))); - - self.inner - .source_id - .replace(Some(glib::timeout_add_local_once( - self.inner.duration, - clone!(@weak self.inner as inner => move || { - inner.state.set(State::Done); - - if let Some(source_id) = inner.secs_left_changed_source_id.take() { - source_id.remove(); - } - - if let Some(waker) = inner.waker.take() { - waker.wake(); - } - }), - ))); - self.inner.instant.set(Some(Instant::now())); - (self.inner.secs_left_changed_cb)(self.inner.secs_left()); - - self.inner.state.get().to_poll() - } -} - -impl FusedFuture for Timer { - fn is_terminated(&self) -> bool { - self.inner.is_terminated() - } -} - -impl Drop for Timer { - fn drop(&mut self) { - self.cancel(); - } -} - -#[cfg(test)] -mod tests { - use super::*; - - use futures_util::FutureExt; - - #[gtk::test] - async fn normal() { - let timer = Timer::new(Duration::from_nanos(10), |_| {}); - assert_eq!(timer.inner.duration, Duration::from_nanos(10)); - assert!(matches!(timer.inner.state.get(), State::Waiting)); - - assert!(timer.clone().await.is_ok()); - assert!(matches!(timer.inner.state.get(), State::Done)); - assert_eq!(timer.inner.secs_left(), 0); - } - - #[gtk::test] - async fn cancelled() { - let timer = Timer::new(Duration::from_nanos(10), |_| {}); - assert!(matches!(timer.inner.state.get(), State::Waiting)); - - timer.cancel(); - - assert!(timer.clone().await.is_err()); - assert!(matches!(timer.inner.state.get(), State::Cancelled)); - assert_eq!(timer.inner.secs_left(), 0); - } - - #[gtk::test] - fn zero_duration() { - let control = Timer::new(Duration::from_nanos(10), |_| {}); - assert!(control.now_or_never().is_none()); - - let timer = Timer::new(Duration::ZERO, |_| {}); - - assert!(timer.clone().now_or_never().unwrap().is_ok()); - assert!(matches!(timer.inner.state.get(), State::Done)); - assert_eq!(timer.inner.secs_left(), 0); - } -} diff --git a/src/win.rs b/src/win.rs deleted file mode 100644 index ca7e8f44..00000000 --- a/src/win.rs +++ /dev/null @@ -1,516 +0,0 @@ -use adw::{prelude::*, subclass::prelude::*}; -use anyhow::{Context, Result}; -use gettextrs::gettext; -use gtk::{ - gio, - glib::{self, clone}, -}; - -use crate::{ - application::Application, - config::PROFILE, - pipeline::{CropData, Pipeline, RecordingState}, - screencast_session::{CursorMode, PersistMode, ScreencastSession, SourceType}, - toggle_button::ToggleButton, - utils, - view_port::{Selection, ViewPort}, -}; - -mod imp { - use std::cell::{Cell, RefCell}; - - use super::*; - - #[derive(Default, gtk::CompositeTemplate)] - #[template(resource = "/io/github/seadve/Kooha/ui/win.ui")] - pub struct Win { - #[template_child] - pub(super) record_button: TemplateChild, - #[template_child] - pub(super) view_port: TemplateChild, - #[template_child] - pub(super) selection_toggle: TemplateChild, - #[template_child] - pub(super) desktop_audio_level_left: TemplateChild, - #[template_child] - pub(super) desktop_audio_level_right: TemplateChild, - #[template_child] - pub(super) microphone_level_left: TemplateChild, - #[template_child] - pub(super) microphone_level_right: TemplateChild, - #[template_child] - pub(super) recording_indicator: TemplateChild, - #[template_child] - pub(super) recording_time_label: TemplateChild, - #[template_child] - pub(super) info_label: TemplateChild, - - pub(super) pipeline: Pipeline, - pub(super) session: RefCell>, - pub(super) prev_selection: Cell>, - } - - #[glib::object_subclass] - impl ObjectSubclass for Win { - const NAME: &'static str = "KoohaWin"; - type Type = super::Win; - type ParentType = adw::ApplicationWindow; - - fn class_init(klass: &mut Self::Class) { - ToggleButton::ensure_type(); - - klass.bind_template(); - - klass.install_action_async("win.select-video-source", None, |obj, _, _| async move { - if let Err(err) = obj.replace_session(None).await { - tracing::error!("Failed to replace session: {:?}", err); - } - }); - - klass.install_action("win.toggle-record", None, |obj, _, _| { - let imp = obj.imp(); - - match imp.pipeline.recording_state() { - RecordingState::Idle => { - if let Err(err) = obj.start_recording() { - tracing::error!("Failed to start recording: {:?}", err); - } - } - RecordingState::Started { .. } => { - if let Err(err) = obj.stop_recording() { - tracing::error!("Failed to stop recording: {:?}", err); - } - } - } - }); - } - - fn instance_init(obj: &glib::subclass::InitializingObject) { - obj.init_template(); - } - } - - impl ObjectImpl for Win { - fn constructed(&self) { - self.parent_constructed(); - - let obj = self.obj(); - - if PROFILE == "Devel" { - obj.add_css_class("devel"); - } - - obj.setup_settings(); - - self.selection_toggle - .connect_active_notify(clone!(@weak obj => move |toggle| { - let imp = obj.imp(); - if toggle.is_active() { - let selection = obj.imp().prev_selection.get().unwrap_or_else(|| { - let mid_x = imp.view_port.width() as f32 / 2.0; - let mid_y = imp.view_port.height() as f32 / 2.0; - let offset = 20.0 * imp.view_port.scale_factor() as f32; - Selection::new( - mid_x - offset, - mid_y - offset, - mid_x + offset, - mid_y + offset, - ) - }); - imp.view_port.set_selection(Some(selection)); - } else { - imp.view_port.set_selection(None::); - } - })); - self.view_port - .connect_paintable_notify(clone!(@weak obj => move |_| { - obj.update_selection_toggle_sensitivity(); - obj.update_info_label(); - })); - self.view_port - .connect_selection_notify(clone!(@weak obj => move |view_port| { - if let Some(selection) = view_port.selection() { - obj.imp().prev_selection.replace(Some(selection)); - } - obj.update_selection_toggle(); - obj.update_info_label(); - })); - - self.pipeline - .connect_stream_size_notify(clone!(@weak obj => move |_| { - obj.update_info_label(); - })); - self.pipeline - .connect_recording_state_notify(clone!(@weak obj => move |_| { - obj.update_recording_ui(); - })); - self.pipeline - .connect_desktop_audio_peak(clone!(@weak obj => move |_, peaks| { - let imp = obj.imp(); - imp.desktop_audio_level_left.set_value(peaks.left()); - imp.desktop_audio_level_right.set_value(peaks.right()); - })); - self.pipeline - .connect_microphone_peak(clone!(@weak obj => move |_, peaks| { - let imp = obj.imp(); - imp.microphone_level_left.set_value(peaks.left()); - imp.microphone_level_right.set_value(peaks.right()); - })); - self.view_port - .set_paintable(Some(self.pipeline.paintable())); - - obj.load_window_size(); - - glib::spawn_future_local(clone!(@weak obj => async move { - if let Err(err) = obj.load_session().await { - tracing::error!("Failed to load session: {:?}", err); - } - })); - - obj.update_selection_toggle_sensitivity(); - obj.update_selection_toggle(); - obj.update_info_label(); - obj.update_recording_ui(); - obj.update_desktop_audio_pipeline(); - obj.update_microphone_pipeline(); - } - - fn dispose(&self) { - let obj = self.obj(); - - obj.close_session(); - - self.dispose_template(); - } - } - - impl WidgetImpl for Win {} - - impl WindowImpl for Win { - fn close_request(&self) -> glib::Propagation { - let obj = self.obj(); - - if let Err(err) = obj.save_window_size() { - tracing::warn!("Failed to save window state, {:?}", &err); - } - - self.parent_close_request() - } - } - - impl ApplicationWindowImpl for Win {} - impl AdwApplicationWindowImpl for Win {} -} - -glib::wrapper! { - pub struct Win(ObjectSubclass) - @extends gtk::Widget, gtk::Window, gtk::ApplicationWindow, adw::ApplicationWindow, - @implements gio::ActionMap, gio::ActionGroup, gtk::Native; -} - -impl Win { - pub fn new(application: &Application) -> Self { - glib::Object::builder() - .property("application", application) - .build() - } - - fn start_recording(&self) -> Result<()> { - let imp = self.imp(); - - let app = utils::app_instance(); - let settings = app.settings(); - - let crop_data = imp.view_port.selection().map(|selection| CropData { - full_rect: imp.view_port.paintable_rect().unwrap(), - selection_rect: selection.rect(), - }); - imp.pipeline - .start_recording(&settings.saving_location(), crop_data)?; - - Ok(()) - } - - fn stop_recording(&self) -> Result<()> { - let imp = self.imp(); - - imp.pipeline.stop_recording()?; - - Ok(()) - } - - fn close_session(&self) { - let imp = self.imp(); - - if let Some(session) = imp.session.take() { - glib::spawn_future_local(async move { - if let Err(err) = session.close().await { - tracing::error!("Failed to end ScreencastSession: {:?}", err); - } - }); - } - } - - async fn replace_session(&self, restore_token: Option<&str>) -> Result<()> { - let imp = self.imp(); - - let app = utils::app_instance(); - let settings = app.settings(); - - let session = ScreencastSession::new() - .await - .context("Failed to create ScreencastSession")?; - - tracing::debug!( - version = ?session.version(), - available_cursor_modes = ?session.available_cursor_modes(), - available_source_types = ?session.available_source_types(), - "Screencast session created" - ); - - let (streams, restore_token, fd) = session - .begin( - if settings.show_pointer() { - CursorMode::EMBEDDED - } else { - CursorMode::HIDDEN - }, - if utils::is_experimental_mode() { - SourceType::MONITOR | SourceType::WINDOW - } else { - SourceType::MONITOR - }, - true, - restore_token, - PersistMode::ExplicitlyRevoked, - Some(self), - ) - .await - .context("Failed to begin ScreencastSession")?; - settings.set_screencast_restore_token(&restore_token.unwrap_or_default()); - - self.close_session(); - - imp.pipeline.set_streams(&streams, fd)?; - imp.session.replace(Some(session)); - - Ok(()) - } - - async fn load_session(&self) -> Result<()> { - let app = utils::app_instance(); - let settings = app.settings(); - - let restore_token = settings.screencast_restore_token(); - settings.set_screencast_restore_token(""); - - self.replace_session(Some(&restore_token)).await?; - - Ok(()) - } - - fn load_window_size(&self) { - let app = utils::app_instance(); - let settings = app.settings(); - - self.set_default_size(settings.window_width(), settings.window_height()); - - if settings.window_maximized() { - self.maximize(); - } - } - - fn save_window_size(&self) -> Result<()> { - let app = utils::app_instance(); - let settings = app.settings(); - - let (width, height) = self.default_size(); - - settings.try_set_window_width(width)?; - settings.try_set_window_height(height)?; - - settings.try_set_window_maximized(self.is_maximized())?; - - Ok(()) - } - - fn update_desktop_audio_pipeline(&self) { - let imp = self.imp(); - - let app = utils::app_instance(); - let settings = app.settings(); - - if settings.record_speaker() { - glib::spawn_future_local(clone!(@weak self as obj => async move { - if let Err(err) = obj.imp().pipeline.load_desktop_audio().await { - tracing::error!("Failed to load desktop audio: {:?}", err); - } - })); - } else { - if let Err(err) = imp.pipeline.unload_desktop_audio() { - tracing::error!("Failed to unload desktop audio: {:?}", err); - } - - imp.desktop_audio_level_left.set_value(0.0); - imp.desktop_audio_level_right.set_value(0.0); - } - } - - fn update_microphone_pipeline(&self) { - let imp = self.imp(); - - let app = utils::app_instance(); - let settings = app.settings(); - - if settings.record_mic() { - glib::spawn_future_local(clone!(@weak self as obj => async move { - if let Err(err) = obj.imp().pipeline.load_microphone().await { - tracing::error!("Failed to load microphone: {:?}", err); - } - })); - } else { - if let Err(err) = imp.pipeline.unload_microphone() { - tracing::error!("Failed to unload microphone: {:?}", err); - } - - imp.microphone_level_left.set_value(0.0); - imp.microphone_level_right.set_value(0.0); - } - } - - fn update_selection_toggle_sensitivity(&self) { - let imp = self.imp(); - - imp.selection_toggle - .set_sensitive(imp.view_port.paintable().is_some()); - } - - fn update_selection_toggle(&self) { - let imp = self.imp(); - - imp.selection_toggle - .set_active(imp.view_port.selection().is_some()); - } - - fn update_info_label(&self) { - let imp = self.imp(); - - let app = utils::app_instance(); - let settings = app.settings(); - - let mut info_list = vec![ - settings - .profile() - .map_or_else(|| gettext("No Profile"), |profile| profile.name()), - format!("{} FPS", settings.video_framerate()), - ]; - - match (imp.pipeline.stream_size(), imp.view_port.selection()) { - (Some(stream_size), Some(selection)) => { - let paintable_rect = imp.view_port.paintable_rect().unwrap(); - let scale_factor_h = stream_size.width() as f32 / paintable_rect.width(); - let scale_factor_v = stream_size.height() as f32 / paintable_rect.height(); - let selection_rect_scaled = selection.rect().scale(scale_factor_h, scale_factor_v); - info_list.push(format!( - "{}×{}", - selection_rect_scaled.width().round() as i32, - selection_rect_scaled.height().round() as i32, - )); - } - (Some(stream_size), None) => { - info_list.push(format!("{}×{}", stream_size.width(), stream_size.height())); - } - _ => {} - } - - imp.info_label.set_label(&info_list.join(" • ")); - } - - fn update_recording_ui(&self) { - let imp = self.imp(); - - match imp.pipeline.recording_state() { - RecordingState::Idle => { - imp.record_button.set_label(&gettext("Record")); - imp.record_button.remove_css_class("destructive-action"); - imp.record_button.add_css_class("suggested-action"); - - imp.recording_indicator.remove_css_class("red"); - imp.recording_indicator.add_css_class("dim-label"); - - imp.recording_time_label.set_label("00∶00∶00"); - } - RecordingState::Started { duration } => { - imp.record_button.set_label(&gettext("Stop")); - imp.record_button.add_css_class("destructive-action"); - imp.record_button.remove_css_class("suggested-action"); - - imp.recording_indicator.remove_css_class("dim-label"); - imp.recording_indicator.add_css_class("red"); - - let secs = duration.seconds(); - let hours_display = secs / 3600; - let minutes_display = (secs / 60) % 60; - let seconds_display = secs % 60; - imp.recording_time_label.set_label(&format!( - "{:02}∶{:02}∶{:02}", - hours_display, minutes_display, seconds_display - )); - } - } - } - - fn update_desktop_audio_level_sensitivity(&self) { - let imp = self.imp(); - - let app = utils::app_instance(); - let settings = app.settings(); - - let is_record_desktop_audio = settings.record_speaker(); - imp.desktop_audio_level_left - .set_sensitive(is_record_desktop_audio); - imp.desktop_audio_level_right - .set_sensitive(is_record_desktop_audio); - } - - fn update_microphone_level_sensitivity(&self) { - let imp = self.imp(); - - let app = utils::app_instance(); - let settings = app.settings(); - - let is_record_microphone = settings.record_mic(); - imp.microphone_level_left - .set_sensitive(is_record_microphone); - imp.microphone_level_right - .set_sensitive(is_record_microphone); - } - - fn setup_settings(&self) { - let app = utils::app_instance(); - let settings = app.settings(); - - self.add_action(&settings.create_record_speaker_action()); - self.add_action(&settings.create_record_mic_action()); - self.add_action(&settings.create_show_pointer_action()); - - settings.connect_record_speaker_changed(clone!(@weak self as obj => move |_| { - obj.update_desktop_audio_level_sensitivity(); - obj.update_desktop_audio_pipeline(); - })); - settings.connect_record_mic_changed(clone!(@weak self as obj => move |_| { - obj.update_microphone_level_sensitivity(); - obj.update_microphone_pipeline(); - })); - - settings.connect_video_framerate_changed(clone!(@weak self as obj => move |_| { - obj.update_info_label(); - })); - settings.connect_profile_changed(clone!(@weak self as obj => move |_| { - obj.update_info_label(); - })); - - self.update_desktop_audio_level_sensitivity(); - self.update_microphone_level_sensitivity(); - } -} diff --git a/src/window.rs b/src/window.rs index e4ae1502..985beaee 100644 --- a/src/window.rs +++ b/src/window.rs @@ -1,54 +1,53 @@ use adw::{prelude::*, subclass::prelude::*}; -use anyhow::{ensure, Error, Result}; +use anyhow::{Context, Result}; use gettextrs::gettext; use gtk::{ gio, glib::{self, clone}, - CompositeTemplate, }; -use std::cell::RefCell; - use crate::{ - cancelled::Cancelled, + application::Application, config::PROFILE, - help::Help, - recording::{NoProfileError, Recording, State as RecordingState}, - settings::CaptureMode, + pipeline::{CropData, Pipeline, RecordingState}, + screencast_session::{CursorMode, PersistMode, ScreencastSession, SourceType}, toggle_button::ToggleButton, - utils, Application, + utils, + view_port::{Selection, ViewPort}, }; mod imp { + use std::cell::{Cell, RefCell}; + use super::*; - #[derive(Debug, Default, CompositeTemplate)] + #[derive(Default, gtk::CompositeTemplate)] #[template(resource = "/io/github/seadve/Kooha/ui/window.ui")] pub struct Window { #[template_child] - pub(super) title: TemplateChild, + pub(super) record_button: TemplateChild, #[template_child] - pub(super) stack: TemplateChild, + pub(super) view_port: TemplateChild, #[template_child] - pub(super) main_page: TemplateChild, + pub(super) selection_toggle: TemplateChild, #[template_child] - pub(super) forget_video_sources_revealer: TemplateChild, + pub(super) desktop_audio_level_left: TemplateChild, #[template_child] - pub(super) recording_page: TemplateChild, + pub(super) desktop_audio_level_right: TemplateChild, #[template_child] - pub(super) recording_label: TemplateChild, + pub(super) microphone_level_left: TemplateChild, #[template_child] - pub(super) recording_time_label: TemplateChild, + pub(super) microphone_level_right: TemplateChild, #[template_child] - pub(super) pause_record_button: TemplateChild, + pub(super) recording_indicator: TemplateChild, #[template_child] - pub(super) delay_page: TemplateChild, - #[template_child] - pub(super) delay_label: TemplateChild, + pub(super) recording_time_label: TemplateChild, #[template_child] - pub(super) flushing_page: TemplateChild, + pub(super) info_label: TemplateChild, - pub(super) recording: RefCell)>>, + pub(super) pipeline: Pipeline, + pub(super) session: RefCell>, + pub(super) prev_selection: Cell>, } #[glib::object_subclass] @@ -58,29 +57,31 @@ mod imp { type ParentType = adw::ApplicationWindow; fn class_init(klass: &mut Self::Class) { - ToggleButton::static_type(); - klass.bind_template(); + ToggleButton::ensure_type(); - klass.install_action_async("win.toggle-record", None, |obj, _, _| async move { - obj.toggle_record().await; - }); + klass.bind_template(); - klass.install_action("win.toggle-pause", None, move |obj, _, _| { - if let Err(err) = obj.toggle_pause() { - let err = err.context(gettext("Failed to toggle pause")); - tracing::error!("{:?}", err); - obj.present_error(&err); + klass.install_action_async("win.select-video-source", None, |obj, _, _| async move { + if let Err(err) = obj.replace_session(None).await { + tracing::error!("Failed to replace session: {:?}", err); } }); - klass.install_action("win.cancel-record", None, move |obj, _, _| { - obj.cancel_record(); - }); - - klass.install_action("win.forget-video-sources", None, move |_obj, _, _| { - utils::app_instance() - .settings() - .set_screencast_restore_token(""); + klass.install_action("win.toggle-record", None, |obj, _, _| { + let imp = obj.imp(); + + match imp.pipeline.recording_state() { + RecordingState::Idle => { + if let Err(err) = obj.start_recording() { + tracing::error!("Failed to start recording: {:?}", err); + } + } + RecordingState::Started { .. } => { + if let Err(err) = obj.stop_recording() { + tracing::error!("Failed to stop recording: {:?}", err); + } + } + } }); } @@ -101,14 +102,102 @@ mod imp { obj.setup_settings(); - obj.update_view(); - obj.update_audio_toggles_sensitivity(); - obj.update_title_label(); + self.selection_toggle + .connect_active_notify(clone!(@weak obj => move |toggle| { + let imp = obj.imp(); + if toggle.is_active() { + let selection = obj.imp().prev_selection.get().unwrap_or_else(|| { + let mid_x = imp.view_port.width() as f32 / 2.0; + let mid_y = imp.view_port.height() as f32 / 2.0; + let offset = 20.0 * imp.view_port.scale_factor() as f32; + Selection::new( + mid_x - offset, + mid_y - offset, + mid_x + offset, + mid_y + offset, + ) + }); + imp.view_port.set_selection(Some(selection)); + } else { + imp.view_port.set_selection(None::); + } + })); + self.view_port + .connect_paintable_notify(clone!(@weak obj => move |_| { + obj.update_selection_toggle_sensitivity(); + obj.update_info_label(); + })); + self.view_port + .connect_selection_notify(clone!(@weak obj => move |view_port| { + if let Some(selection) = view_port.selection() { + obj.imp().prev_selection.replace(Some(selection)); + } + obj.update_selection_toggle(); + obj.update_info_label(); + })); + + self.pipeline + .connect_stream_size_notify(clone!(@weak obj => move |_| { + obj.update_info_label(); + })); + self.pipeline + .connect_recording_state_notify(clone!(@weak obj => move |_| { + obj.update_recording_ui(); + })); + self.pipeline + .connect_desktop_audio_peak(clone!(@weak obj => move |_, peaks| { + let imp = obj.imp(); + imp.desktop_audio_level_left.set_value(peaks.left()); + imp.desktop_audio_level_right.set_value(peaks.right()); + })); + self.pipeline + .connect_microphone_peak(clone!(@weak obj => move |_, peaks| { + let imp = obj.imp(); + imp.microphone_level_left.set_value(peaks.left()); + imp.microphone_level_right.set_value(peaks.right()); + })); + self.view_port + .set_paintable(Some(self.pipeline.paintable())); + + obj.load_window_size(); + + glib::spawn_future_local(clone!(@weak obj => async move { + if let Err(err) = obj.load_session().await { + tracing::error!("Failed to load session: {:?}", err); + } + })); + + obj.update_selection_toggle_sensitivity(); + obj.update_selection_toggle(); + obj.update_info_label(); + obj.update_recording_ui(); + obj.update_desktop_audio_pipeline(); + obj.update_microphone_pipeline(); + } + + fn dispose(&self) { + let obj = self.obj(); + + obj.close_session(); + + self.dispose_template(); } } impl WidgetImpl for Window {} - impl WindowImpl for Window {} + + impl WindowImpl for Window { + fn close_request(&self) -> glib::Propagation { + let obj = self.obj(); + + if let Err(err) = obj.save_window_size() { + tracing::warn!("Failed to save window state, {:?}", &err); + } + + self.parent_close_request() + } + } + impl ApplicationWindowImpl for Window {} impl AdwApplicationWindowImpl for Window {} } @@ -120,370 +209,308 @@ glib::wrapper! { } impl Window { - pub fn new(app: &Application) -> Self { - glib::Object::builder().property("application", app).build() + pub fn new(application: &Application) -> Self { + glib::Object::builder() + .property("application", application) + .build() } - pub fn close(&self) -> Result<()> { - let is_safe_to_close = - self.imp() - .recording - .borrow() - .as_ref() - .map_or(true, |(ref recording, _)| { - matches!( - recording.state(), - RecordingState::Init - | RecordingState::Delayed { .. } - | RecordingState::Finished - ) - }); - - ensure!( - is_safe_to_close, - "Cannot close window while recording is in progress" - ); + fn start_recording(&self) -> Result<()> { + let imp = self.imp(); + + let app = utils::app_instance(); + let settings = app.settings(); + + let crop_data = imp.view_port.selection().map(|selection| CropData { + full_rect: imp.view_port.paintable_rect().unwrap(), + selection_rect: selection.rect(), + }); + imp.pipeline + .start_recording(&settings.saving_location(), crop_data)?; - GtkWindowExt::close(self); Ok(()) } - pub fn present_error(&self, err: &Error) { - let err_text = format!("{:?}", err); - - let err_view = gtk::TextView::builder() - .buffer(>k::TextBuffer::builder().text(&err_text).build()) - .editable(false) - .monospace(true) - .top_margin(6) - .bottom_margin(6) - .left_margin(6) - .right_margin(6) - .build(); - - let scrolled_window = gtk::ScrolledWindow::builder() - .child(&err_view) - .min_content_height(120) - .min_content_width(360) - .build(); - - let scrolled_window_row = gtk::ListBoxRow::builder() - .child(&scrolled_window) - .overflow(gtk::Overflow::Hidden) - .activatable(false) - .selectable(false) - .build(); - scrolled_window_row.add_css_class("error-view"); - - let copy_button = gtk::Button::builder() - .tooltip_text(gettext("Copy to clipboard")) - .icon_name("edit-copy-symbolic") - .valign(gtk::Align::Center) - .build(); - copy_button.connect_clicked(move |button| { - button.display().clipboard().set_text(&err_text); - button.set_tooltip_text(Some(&gettext("Copied to clipboard"))); - button.set_icon_name("checkmark-symbolic"); - button.add_css_class("copy-done"); - }); + fn stop_recording(&self) -> Result<()> { + let imp = self.imp(); - let expander = adw::ExpanderRow::builder() - .title(gettext("Show detailed error")) - .activatable(false) - .build(); - expander.add_row(&scrolled_window_row); - expander.add_suffix(©_button); - - let list_box = gtk::ListBox::builder() - .selection_mode(gtk::SelectionMode::None) - .build(); - list_box.add_css_class("boxed-list"); - list_box.append(&expander); - - let err_dialog = adw::MessageDialog::builder() - .heading(err.to_string()) - .body_use_markup(true) - .default_response("ok") - .transient_for(self) - .modal(true) - .extra_child(&list_box) - .build(); - - if let Some(ref help) = err.downcast_ref::() { - err_dialog.set_body(&format!("{}: {}", gettext("Help"), help)); - } + imp.pipeline.stop_recording()?; - err_dialog.add_response("ok", &gettext("Ok")); - err_dialog.present(); + Ok(()) } - async fn toggle_record(&self) { + fn close_session(&self) { let imp = self.imp(); - if let Some((ref recording, _)) = *imp.recording.borrow() { - recording.stop(); - return; + if let Some(session) = imp.session.take() { + glib::spawn_future_local(async move { + if let Err(err) = session.close().await { + tracing::error!("Failed to end ScreencastSession: {:?}", err); + } + }); } + } - let recording = Recording::new(); - let handler_ids = vec![ - recording.connect_state_notify(clone!(@weak self as obj => move |_| { - obj.update_view(); - })), - recording.connect_duration_notify(clone!(@weak self as obj => move |recording| { - let formatted_time = format_time(recording.duration()); - obj.imp().recording_time_label.set_label(&formatted_time); - })), - recording.connect_finished(clone!(@weak self as obj => move |recording, res| { - obj.on_recording_finished(recording, res); - })), - ]; - imp.recording - .replace(Some((recording.clone(), handler_ids))); + async fn replace_session(&self, restore_token: Option<&str>) -> Result<()> { + let imp = self.imp(); + + let app = utils::app_instance(); + let settings = app.settings(); - recording - .start(Some(self), utils::app_instance().settings()) - .await; + let session = ScreencastSession::new() + .await + .context("Failed to create ScreencastSession")?; + + tracing::debug!( + version = ?session.version(), + available_cursor_modes = ?session.available_cursor_modes(), + available_source_types = ?session.available_source_types(), + "Screencast session created" + ); + + let (streams, restore_token, fd) = session + .begin( + if settings.show_pointer() { + CursorMode::EMBEDDED + } else { + CursorMode::HIDDEN + }, + if utils::is_experimental_mode() { + SourceType::MONITOR | SourceType::WINDOW + } else { + SourceType::MONITOR + }, + true, + restore_token, + PersistMode::ExplicitlyRevoked, + Some(self), + ) + .await + .context("Failed to begin ScreencastSession")?; + settings.set_screencast_restore_token(&restore_token.unwrap_or_default()); + + self.close_session(); + + imp.pipeline.set_streams(&streams, fd)?; + imp.session.replace(Some(session)); + + Ok(()) } - fn toggle_pause(&self) -> Result<()> { - let imp = self.imp(); + async fn load_session(&self) -> Result<()> { + let app = utils::app_instance(); + let settings = app.settings(); - if let Some((ref recording, _)) = *imp.recording.borrow() { - if matches!(recording.state(), RecordingState::Paused) { - recording.resume()?; - } else { - recording.pause()?; - }; - } + let restore_token = settings.screencast_restore_token(); + settings.set_screencast_restore_token(""); + + self.replace_session(Some(&restore_token)).await?; Ok(()) } - fn cancel_record(&self) { - let imp = self.imp(); + fn load_window_size(&self) { + let app = utils::app_instance(); + let settings = app.settings(); - if let Some((ref recording, _)) = *imp.recording.borrow() { - recording.cancel(); + self.set_default_size(settings.window_width(), settings.window_height()); + + if settings.window_maximized() { + self.maximize(); } } - fn on_recording_finished(&self, recording: &Recording, res: &Result) { - debug_assert_eq!(recording.state(), RecordingState::Finished); + fn save_window_size(&self) -> Result<()> { + let app = utils::app_instance(); + let settings = app.settings(); - match res { - Ok(ref recording_file) => { - let application = utils::app_instance(); - application.send_record_success_notification(recording_file); + let (width, height) = self.default_size(); - let recent_manager = gtk::RecentManager::default(); - recent_manager.add_item(&recording_file.uri()); - } - Err(ref err) => { - if err.is::() { - tracing::debug!("{:?}", err); - } else if err.is::() { - const OPEN_RESPONSE: &str = "open"; - const LATER_RESPONSE: &str = "later"; - let d = adw::MessageDialog::builder() - .heading(gettext("Open Preferences?")) - .body(gettext("The previously selected format may have been unavailable. Open preferences and select a format to continue recording.")) - .default_response(OPEN_RESPONSE) - .transient_for(self) - .modal(true) - .build(); - d.add_response(LATER_RESPONSE, &gettext("Later")); - d.add_response(OPEN_RESPONSE, &gettext("Open")); - d.set_response_appearance(OPEN_RESPONSE, adw::ResponseAppearance::Suggested); - d.connect_response(Some(OPEN_RESPONSE), |d, _| { - d.close(); - utils::app_instance().present_preferences(); - }); - d.present(); - } else { - tracing::error!("{:?}", err); - self.surface().beep(); - self.present_error(err); + settings.try_set_window_width(width)?; + settings.try_set_window_height(height)?; + + settings.try_set_window_maximized(self.is_maximized())?; + + Ok(()) + } + + fn update_desktop_audio_pipeline(&self) { + let imp = self.imp(); + + let app = utils::app_instance(); + let settings = app.settings(); + + if settings.record_speaker() { + glib::spawn_future_local(clone!(@weak self as obj => async move { + if let Err(err) = obj.imp().pipeline.load_desktop_audio().await { + tracing::error!("Failed to load desktop audio: {:?}", err); } + })); + } else { + if let Err(err) = imp.pipeline.unload_desktop_audio() { + tracing::error!("Failed to unload desktop audio: {:?}", err); } + + imp.desktop_audio_level_left.set_value(0.0); + imp.desktop_audio_level_right.set_value(0.0); } + } - if let Some((recording, handler_ids)) = self.imp().recording.take() { - for handler_id in handler_ids { - recording.disconnect(handler_id); - } + fn update_microphone_pipeline(&self) { + let imp = self.imp(); + + let app = utils::app_instance(); + let settings = app.settings(); + + if settings.record_mic() { + glib::spawn_future_local(clone!(@weak self as obj => async move { + if let Err(err) = obj.imp().pipeline.load_microphone().await { + tracing::error!("Failed to load microphone: {:?}", err); + } + })); } else { - tracing::warn!("Recording finished but no stored recording"); + if let Err(err) = imp.pipeline.unload_microphone() { + tracing::error!("Failed to unload microphone: {:?}", err); + } + + imp.microphone_level_left.set_value(0.0); + imp.microphone_level_right.set_value(0.0); } } - fn update_view(&self) { + fn update_selection_toggle_sensitivity(&self) { let imp = self.imp(); - // TODO disregard ms granularity recording state change + imp.selection_toggle + .set_sensitive(imp.view_port.paintable().is_some()); + } - let state = imp - .recording - .borrow() - .as_ref() - .map_or(RecordingState::Init, |(recording, _)| recording.state()); + fn update_selection_toggle(&self) { + let imp = self.imp(); - match state { - RecordingState::Init | RecordingState::Finished => { - imp.stack.set_visible_child(&*imp.main_page); + imp.selection_toggle + .set_active(imp.view_port.selection().is_some()); + } - imp.recording_time_label - .set_label(&format_time(gst::ClockTime::ZERO)); - } - RecordingState::Delayed { secs_left } => { - imp.delay_label.set_label(&secs_left.to_string()); + fn update_info_label(&self) { + let imp = self.imp(); - imp.stack.set_visible_child(&*imp.delay_page); - } - RecordingState::Recording => { - imp.pause_record_button - .set_icon_name("media-playback-pause-symbolic"); - imp.recording_label.set_label(&gettext("Recording")); - imp.recording_time_label.remove_css_class("paused"); + let app = utils::app_instance(); + let settings = app.settings(); - imp.stack.set_visible_child(&*imp.recording_page); - } - RecordingState::Paused => { - imp.pause_record_button - .set_icon_name("media-playback-start-symbolic"); - imp.recording_label.set_label(&gettext("Paused")); - imp.recording_time_label.add_css_class("paused"); + let mut info_list = vec![ + settings + .profile() + .map_or_else(|| gettext("No Profile"), |profile| profile.name()), + format!("{} FPS", settings.video_framerate()), + ]; - imp.stack.set_visible_child(&*imp.recording_page); + match (imp.pipeline.stream_size(), imp.view_port.selection()) { + (Some(stream_size), Some(selection)) => { + let paintable_rect = imp.view_port.paintable_rect().unwrap(); + let scale_factor_h = stream_size.width() as f32 / paintable_rect.width(); + let scale_factor_v = stream_size.height() as f32 / paintable_rect.height(); + let selection_rect_scaled = selection.rect().scale(scale_factor_h, scale_factor_v); + info_list.push(format!( + "{}×{}", + selection_rect_scaled.width().round() as i32, + selection_rect_scaled.height().round() as i32, + )); + } + (Some(stream_size), None) => { + info_list.push(format!("{}×{}", stream_size.width(), stream_size.height())); } - RecordingState::Flushing => imp.stack.set_visible_child(&*imp.flushing_page), + _ => {} } - self.action_set_enabled( - "win.toggle-record", - !matches!( - state, - RecordingState::Delayed { .. } | RecordingState::Flushing - ), - ); - self.action_set_enabled( - "win.toggle-pause", - matches!(state, RecordingState::Recording | RecordingState::Paused), - ); - self.action_set_enabled( - "win.cancel-record", - matches!( - state, - RecordingState::Delayed { .. } | RecordingState::Flushing - ), - ); + imp.info_label.set_label(&info_list.join(" • ")); } - fn update_title_label(&self) { + fn update_recording_ui(&self) { let imp = self.imp(); - match utils::app_instance().settings().capture_mode() { - CaptureMode::MonitorWindow => imp.title.set_title(&gettext("Normal")), - CaptureMode::Selection => imp.title.set_title(&gettext("Selection")), + match imp.pipeline.recording_state() { + RecordingState::Idle => { + imp.record_button.set_label(&gettext("Record")); + imp.record_button.remove_css_class("destructive-action"); + imp.record_button.add_css_class("suggested-action"); + + imp.recording_indicator.remove_css_class("red"); + imp.recording_indicator.add_css_class("dim-label"); + + imp.recording_time_label.set_label("00∶00∶00"); + } + RecordingState::Started { duration } => { + imp.record_button.set_label(&gettext("Stop")); + imp.record_button.add_css_class("destructive-action"); + imp.record_button.remove_css_class("suggested-action"); + + imp.recording_indicator.remove_css_class("dim-label"); + imp.recording_indicator.add_css_class("red"); + + let secs = duration.seconds(); + let hours_display = secs / 3600; + let minutes_display = (secs / 60) % 60; + let seconds_display = secs % 60; + imp.recording_time_label.set_label(&format!( + "{:02}∶{:02}∶{:02}", + hours_display, minutes_display, seconds_display + )); + } } } - fn update_audio_toggles_sensitivity(&self) { - let is_enabled = utils::app_instance() - .settings() - .profile() - .map_or(true, |profile| profile.supports_audio()); + fn update_desktop_audio_level_sensitivity(&self) { + let imp = self.imp(); + + let app = utils::app_instance(); + let settings = app.settings(); - self.action_set_enabled("win.record-speaker", is_enabled); - self.action_set_enabled("win.record-mic", is_enabled); + let is_record_desktop_audio = settings.record_speaker(); + imp.desktop_audio_level_left + .set_sensitive(is_record_desktop_audio); + imp.desktop_audio_level_right + .set_sensitive(is_record_desktop_audio); } - fn update_forget_video_sources_action(&self) { - let has_restore_token = !utils::app_instance() - .settings() - .screencast_restore_token() - .is_empty(); + fn update_microphone_level_sensitivity(&self) { + let imp = self.imp(); - self.imp() - .forget_video_sources_revealer - .set_reveal_child(has_restore_token); + let app = utils::app_instance(); + let settings = app.settings(); - self.action_set_enabled("win.forget-video-sources", has_restore_token); + let is_record_microphone = settings.record_mic(); + imp.microphone_level_left + .set_sensitive(is_record_microphone); + imp.microphone_level_right + .set_sensitive(is_record_microphone); } fn setup_settings(&self) { let app = utils::app_instance(); let settings = app.settings(); - settings.connect_capture_mode_changed(clone!(@weak self as obj => move |_| { - obj.update_title_label(); - })); - - settings.connect_profile_changed(clone!(@weak self as obj => move |_| { - obj.update_audio_toggles_sensitivity(); - })); - - settings.connect_screencast_restore_token_changed(clone!(@weak self as obj => move |_| { - obj.update_forget_video_sources_action(); - })); - - self.update_title_label(); - self.update_audio_toggles_sensitivity(); - self.update_forget_video_sources_action(); - self.add_action(&settings.create_record_speaker_action()); self.add_action(&settings.create_record_mic_action()); self.add_action(&settings.create_show_pointer_action()); - self.add_action(&settings.create_capture_mode_action()); - } -} - -/// Format time in MM:SS. The MM part will be more than 2 digits -/// if the time is >= 1 hour. -fn format_time(clock_time: gst::ClockTime) -> String { - let secs = clock_time.seconds(); - let seconds_display = secs % 60; - let minutes_display = secs / 60; - format!("{:02}∶{:02}", minutes_display, seconds_display) -} - -#[cfg(test)] -mod tests { - use super::*; + settings.connect_record_speaker_changed(clone!(@weak self as obj => move |_| { + obj.update_desktop_audio_level_sensitivity(); + obj.update_desktop_audio_pipeline(); + })); + settings.connect_record_mic_changed(clone!(@weak self as obj => move |_| { + obj.update_microphone_level_sensitivity(); + obj.update_microphone_pipeline(); + })); - #[test] - fn format_time_less_than_1_hour() { - assert_eq!(format_time(gst::ClockTime::ZERO), "00∶00"); - assert_eq!(format_time(gst::ClockTime::from_seconds(31)), "00∶31"); - assert_eq!( - format_time(gst::ClockTime::from_seconds(8 * 60 + 1)), - "08∶01" - ); - assert_eq!( - format_time(gst::ClockTime::from_seconds(33 * 60 + 3)), - "33∶03" - ); - assert_eq!( - format_time(gst::ClockTime::from_seconds(59 * 60 + 59)), - "59∶59" - ); - } + settings.connect_video_framerate_changed(clone!(@weak self as obj => move |_| { + obj.update_info_label(); + })); + settings.connect_profile_changed(clone!(@weak self as obj => move |_| { + obj.update_info_label(); + })); - #[test] - fn format_time_more_than_1_hour() { - assert_eq!(format_time(gst::ClockTime::from_seconds(60 * 60)), "60∶00"); - assert_eq!( - format_time(gst::ClockTime::from_seconds(60 * 60 + 9)), - "60∶09" - ); - assert_eq!( - format_time(gst::ClockTime::from_seconds(60 * 60 + 31)), - "60∶31" - ); - assert_eq!( - format_time(gst::ClockTime::from_seconds(100 * 60 + 20)), - "100∶20" - ); + self.update_desktop_audio_level_sensitivity(); + self.update_microphone_level_sensitivity(); } } From bc596bd64df395e4628b4d0a1ea408c352d3cf15 Mon Sep 17 00:00:00 2001 From: Dave Patrick Caberto Date: Tue, 28 Nov 2023 11:21:28 +0800 Subject: [PATCH 57/77] misc: wire up record delay --- src/main.rs | 1 + src/pipeline.rs | 3 - src/timer.rs | 223 ++++++++++++++++++++++++++++++++++++++++++++++++ src/window.rs | 81 ++++++++++++++---- 4 files changed, 287 insertions(+), 21 deletions(-) create mode 100644 src/timer.rs diff --git a/src/main.rs b/src/main.rs index 289ddb79..31ee82b4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -35,6 +35,7 @@ mod preferences_window; mod profile; mod screencast_session; mod settings; +mod timer; mod toggle_button; mod utils; mod view_port; diff --git a/src/pipeline.rs b/src/pipeline.rs index 145a9e6b..4a06cc55 100644 --- a/src/pipeline.rs +++ b/src/pipeline.rs @@ -83,9 +83,6 @@ impl Peaks { pub enum RecordingState { #[default] Idle, - // Delayed { - // secs_left: u32, - // }, Started { duration: gst::ClockTime, }, diff --git a/src/timer.rs b/src/timer.rs new file mode 100644 index 00000000..2afbe8ff --- /dev/null +++ b/src/timer.rs @@ -0,0 +1,223 @@ +use futures_util::future::FusedFuture; +use gtk::glib::{self, clone}; + +use std::{ + cell::{Cell, RefCell}, + fmt, + future::Future, + pin::Pin, + rc::Rc, + task::{Context, Poll, Waker}, + time::{Duration, Instant}, +}; + +use crate::cancelled::Cancelled; + +const DEFAULT_SECS_LEFT_UPDATE_INTERVAL: Duration = Duration::from_millis(200); + +/// Reference counted cancellable timer future +#[derive(Clone)] +pub struct Timer { + inner: Rc, +} + +impl fmt::Debug for Timer { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Timer") + .field("duration", &self.inner.duration) + .field("state", &self.inner.state.get()) + .field("elapsed", &self.inner.instant.get().map(|i| i.elapsed())) + .finish() + } +} + +#[derive(Debug, Clone, Copy)] +enum State { + Waiting, + Cancelled, + Done, +} + +impl State { + fn to_poll(self) -> Poll<::Output> { + match self { + State::Waiting => Poll::Pending, + State::Cancelled => Poll::Ready(Err(Cancelled::new("timer"))), + State::Done => Poll::Ready(Ok(())), + } + } +} + +struct Inner { + duration: Duration, + + secs_left_changed_cb: Box, + secs_left_changed_source_id: RefCell>, + + state: Cell, + + instant: Cell>, + waker: RefCell>, + source_id: RefCell>, +} + +impl Inner { + fn secs_left(&self) -> u64 { + if self.is_terminated() { + return 0; + } + + let elapsed_secs = self + .instant + .get() + .map_or(Duration::ZERO, |instant| instant.elapsed()) + .as_secs(); + + self.duration.as_secs() - elapsed_secs + } + + fn is_terminated(&self) -> bool { + matches!(self.state.get(), State::Done | State::Cancelled) + } +} + +impl Timer { + /// The timer will start as soon as it gets polled + pub fn new(duration: Duration, secs_left_changed_cb: impl Fn(u64) + 'static) -> Self { + Self { + inner: Rc::new(Inner { + duration, + secs_left_changed_cb: Box::new(secs_left_changed_cb), + secs_left_changed_source_id: RefCell::new(None), + state: Cell::new(State::Waiting), + instant: Cell::new(None), + waker: RefCell::new(None), + source_id: RefCell::new(None), + }), + } + } + + pub fn cancel(&self) { + if self.inner.is_terminated() { + return; + } + + self.inner.state.set(State::Cancelled); + + if let Some(source_id) = self.inner.source_id.take() { + source_id.remove(); + } + + if let Some(source_id) = self.inner.secs_left_changed_source_id.take() { + source_id.remove(); + } + + if let Some(waker) = self.inner.waker.take() { + waker.wake(); + } + } +} + +impl Future for Timer { + type Output = Result<(), Cancelled>; + + fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + match self.inner.state.get().to_poll() { + ready @ Poll::Ready(_) => return ready, + Poll::Pending => {} + } + + if self.inner.duration == Duration::ZERO { + self.inner.state.set(State::Done); + return Poll::Ready(Ok(())); + } + + let waker = cx.waker().clone(); + self.inner.waker.replace(Some(waker)); + + self.inner + .secs_left_changed_source_id + .replace(Some(glib::timeout_add_local( + DEFAULT_SECS_LEFT_UPDATE_INTERVAL, + clone!(@weak self.inner as inner => @default-return glib::ControlFlow::Break, move || { + (inner.secs_left_changed_cb)(inner.secs_left()); + glib::ControlFlow::Continue + }), + ))); + + self.inner + .source_id + .replace(Some(glib::timeout_add_local_once( + self.inner.duration, + clone!(@weak self.inner as inner => move || { + inner.state.set(State::Done); + + if let Some(source_id) = inner.secs_left_changed_source_id.take() { + source_id.remove(); + } + + if let Some(waker) = inner.waker.take() { + waker.wake(); + } + }), + ))); + self.inner.instant.set(Some(Instant::now())); + (self.inner.secs_left_changed_cb)(self.inner.secs_left()); + + self.inner.state.get().to_poll() + } +} + +impl FusedFuture for Timer { + fn is_terminated(&self) -> bool { + self.inner.is_terminated() + } +} + +impl Drop for Timer { + fn drop(&mut self) { + self.cancel(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + use futures_util::FutureExt; + + #[gtk::test] + async fn normal() { + let timer = Timer::new(Duration::from_nanos(10), |_| {}); + assert_eq!(timer.inner.duration, Duration::from_nanos(10)); + assert!(matches!(timer.inner.state.get(), State::Waiting)); + + assert!(timer.clone().await.is_ok()); + assert!(matches!(timer.inner.state.get(), State::Done)); + assert_eq!(timer.inner.secs_left(), 0); + } + + #[gtk::test] + async fn cancelled() { + let timer = Timer::new(Duration::from_nanos(10), |_| {}); + assert!(matches!(timer.inner.state.get(), State::Waiting)); + + timer.cancel(); + + assert!(timer.clone().await.is_err()); + assert!(matches!(timer.inner.state.get(), State::Cancelled)); + assert_eq!(timer.inner.secs_left(), 0); + } + + #[gtk::test] + fn zero_duration() { + let control = Timer::new(Duration::from_nanos(10), |_| {}); + assert!(control.now_or_never().is_none()); + + let timer = Timer::new(Duration::ZERO, |_| {}); + + assert!(timer.clone().now_or_never().unwrap().is_ok()); + assert!(matches!(timer.inner.state.get(), State::Done)); + assert_eq!(timer.inner.secs_left(), 0); + } +} diff --git a/src/window.rs b/src/window.rs index 985beaee..bc53ef41 100644 --- a/src/window.rs +++ b/src/window.rs @@ -8,9 +8,11 @@ use gtk::{ use crate::{ application::Application, + cancelled::Cancelled, config::PROFILE, pipeline::{CropData, Pipeline, RecordingState}, screencast_session::{CursorMode, PersistMode, ScreencastSession, SourceType}, + timer::Timer, toggle_button::ToggleButton, utils, view_port::{Selection, ViewPort}, @@ -46,6 +48,7 @@ mod imp { pub(super) info_label: TemplateChild, pub(super) pipeline: Pipeline, + pub(super) timer: RefCell>, pub(super) session: RefCell>, pub(super) prev_selection: Cell>, } @@ -63,23 +66,20 @@ mod imp { klass.install_action_async("win.select-video-source", None, |obj, _, _| async move { if let Err(err) = obj.replace_session(None).await { - tracing::error!("Failed to replace session: {:?}", err); + if err.is::() { + tracing::debug!("Select video source cancelled: {:?}", err); + } else { + tracing::error!("Failed to select video source: {:?}", err); + } } }); - klass.install_action("win.toggle-record", None, |obj, _, _| { - let imp = obj.imp(); - - match imp.pipeline.recording_state() { - RecordingState::Idle => { - if let Err(err) = obj.start_recording() { - tracing::error!("Failed to start recording: {:?}", err); - } - } - RecordingState::Started { .. } => { - if let Err(err) = obj.stop_recording() { - tracing::error!("Failed to stop recording: {:?}", err); - } + klass.install_action_async("win.toggle-record", None, |obj, _, _| async move { + if let Err(err) = obj.toggle_record().await { + if err.is::() { + tracing::debug!("Recording cancelled: {:?}", err); + } else { + tracing::error!("Failed to toggle record: {:?}", err); } } }); @@ -239,6 +239,42 @@ impl Window { Ok(()) } + async fn toggle_record(&self) -> Result<()> { + let imp = self.imp(); + + match imp.pipeline.recording_state() { + RecordingState::Idle => { + if let Some(timer) = imp.timer.take() { + timer.cancel(); + self.update_recording_ui(); + return Ok(()); + } + + let app = utils::app_instance(); + let settings = app.settings(); + + let timer = Timer::new(settings.record_delay(), |secs_left| { + println!("secs_left: {}", secs_left); + }); + imp.timer.replace(Some(timer.clone())); + self.update_recording_ui(); + + timer.await?; + + let _ = imp.timer.take(); + self.update_recording_ui(); + + self.start_recording() + .context("Failed to start recording")?; + } + RecordingState::Started { .. } => { + self.stop_recording().context("Failed to stop recording")?; + } + } + + Ok(()) + } + fn close_session(&self) { let imp = self.imp(); @@ -431,9 +467,17 @@ impl Window { match imp.pipeline.recording_state() { RecordingState::Idle => { - imp.record_button.set_label(&gettext("Record")); - imp.record_button.remove_css_class("destructive-action"); - imp.record_button.add_css_class("suggested-action"); + if imp.timer.borrow().is_some() { + imp.record_button.set_label(&gettext("Cancel")); + + imp.record_button.remove_css_class("suggested-action"); + imp.record_button.add_css_class("destructive-action"); + } else { + imp.record_button.set_label(&gettext("Record")); + + imp.record_button.remove_css_class("destructive-action"); + imp.record_button.add_css_class("suggested-action"); + } imp.recording_indicator.remove_css_class("red"); imp.recording_indicator.add_css_class("dim-label"); @@ -442,8 +486,9 @@ impl Window { } RecordingState::Started { duration } => { imp.record_button.set_label(&gettext("Stop")); - imp.record_button.add_css_class("destructive-action"); + imp.record_button.remove_css_class("suggested-action"); + imp.record_button.add_css_class("destructive-action"); imp.recording_indicator.remove_css_class("dim-label"); imp.recording_indicator.add_css_class("red"); From 068913511dff9949b3d1ab63a8f52d5ed06aa832 Mon Sep 17 00:00:00 2001 From: Dave Patrick Caberto Date: Tue, 28 Nov 2023 11:28:58 +0800 Subject: [PATCH 58/77] feat: add toast overlay to show errors --- data/resources/ui/window.ui | 322 ++++++++++++++++++------------------ src/window.rs | 9 + 2 files changed, 172 insertions(+), 159 deletions(-) diff --git a/data/resources/ui/window.ui b/data/resources/ui/window.ui index b4fdcc42..646b55c1 100644 --- a/data/resources/ui/window.ui +++ b/data/resources/ui/window.ui @@ -20,154 +20,158 @@ 360 294 - - raised - raised - - - - - win.toggle-record - - - - - primary_menu - open-menu-symbolic - True - Main Menu - - - - - - - vertical - - - True - + + + + raised + raised + + + + + win.toggle-record + + + + + primary_menu + open-menu-symbolic + True + Main Menu + + - + - 12 - 12 - 12 - 12 - 12 + vertical - - vertical - 6 - True + + True + + + + + + 12 + 12 + 12 + 12 + 12 - + + vertical 6 + True - - start - Select Source - win.select-video-source + + 6 + + + start + Select Source + win.select-video-source + + + + + Toggle Selection + crop-symbolic + + + - - Toggle Selection - crop-symbolic - + + 6 + + + win.show-pointer + mouse-wireless-disabled-symbolic + mouse-wireless-symbolic + Show Pointer + Hide Pointer + + + - - 6 - - - win.show-pointer - mouse-wireless-disabled-symbolic - mouse-wireless-symbolic - Show Pointer - Hide Pointer - - - - + - - - - - - - - vertical - 6 - True - + + vertical 6 - - - win.record-speaker - audio-volume-muted-symbolic - audio-volume-high-symbolic - Enable Desktop Audio - Disable Desktop Audio - - - + True - True - center - vertical - 3 + 6 - + + win.record-speaker + audio-volume-muted-symbolic + audio-volume-high-symbolic + Enable Desktop Audio + Disable Desktop Audio + + - + + True + center + vertical + 3 + + + + + + + - - - - - 6 - - - win.record-mic - microphone-disabled-symbolic - microphone2-symbolic - Enable Microphone - Disable Microphone - - - - True - center - vertical - 3 + 6 - + + win.record-mic + microphone-disabled-symbolic + microphone2-symbolic + Enable Microphone + Disable Microphone + + - + + True + center + vertical + 3 + + + + + + + @@ -176,51 +180,51 @@ - - - - - - 12 - 12 - 6 - 6 - 6 - - - circle-filled-symbolic - - - - - True - start - - - - - - 1 - - - - - - info-symbolic - + + + + 12 + 12 + 6 + 6 + 6 + + + circle-filled-symbolic + + + + + True + start + + + + + + 1 + + + + + + info-symbolic + + + - + diff --git a/src/window.rs b/src/window.rs index bc53ef41..392fb265 100644 --- a/src/window.rs +++ b/src/window.rs @@ -26,6 +26,8 @@ mod imp { #[derive(Default, gtk::CompositeTemplate)] #[template(resource = "/io/github/seadve/Kooha/ui/window.ui")] pub struct Window { + #[template_child] + pub(super) toast_overlay: TemplateChild, #[template_child] pub(super) record_button: TemplateChild, #[template_child] @@ -69,6 +71,7 @@ mod imp { if err.is::() { tracing::debug!("Select video source cancelled: {:?}", err); } else { + obj.add_message_toast(&gettext("Failed to select video source")); tracing::error!("Failed to select video source: {:?}", err); } } @@ -79,6 +82,7 @@ mod imp { if err.is::() { tracing::debug!("Recording cancelled: {:?}", err); } else { + obj.add_message_toast(&gettext("Failed to toggle record")); tracing::error!("Failed to toggle record: {:?}", err); } } @@ -215,6 +219,11 @@ impl Window { .build() } + fn add_message_toast(&self, message: &str) { + let toast = adw::Toast::new(message); + self.imp().toast_overlay.add_toast(toast); + } + fn start_recording(&self) -> Result<()> { let imp = self.imp(); From 73342c615ce8195c8160121f886956aaf7be09e4 Mon Sep 17 00:00:00 2001 From: Dave Patrick Caberto Date: Tue, 28 Nov 2023 11:31:57 +0800 Subject: [PATCH 59/77] refactor: more consistent naming --- data/resources/ui/shortcuts.ui | 4 ++-- data/resources/ui/window.ui | 2 +- src/application.rs | 4 ++-- src/window.rs | 6 +++--- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/data/resources/ui/shortcuts.ui b/data/resources/ui/shortcuts.ui index 903b732d..84de6246 100644 --- a/data/resources/ui/shortcuts.ui +++ b/data/resources/ui/shortcuts.ui @@ -39,7 +39,7 @@ Recording - win.toggle-record + win.toggle-recording Toggle Record @@ -52,7 +52,7 @@ - win.cancel-record + win.cancel-recording Cancel Record diff --git a/data/resources/ui/window.ui b/data/resources/ui/window.ui index 646b55c1..5176d246 100644 --- a/data/resources/ui/window.ui +++ b/data/resources/ui/window.ui @@ -29,7 +29,7 @@ - win.toggle-record + win.toggle-recording diff --git a/src/application.rs b/src/application.rs index 9f43f76c..dd4b2fec 100644 --- a/src/application.rs +++ b/src/application.rs @@ -210,9 +210,9 @@ impl Application { self.set_accels_for_action("win.record-speaker", &["a"]); self.set_accels_for_action("win.record-mic", &["m"]); self.set_accels_for_action("win.show-pointer", &["p"]); - self.set_accels_for_action("win.toggle-record", &["r"]); + self.set_accels_for_action("win.toggle-recording", &["r"]); // self.set_accels_for_action("win.toggle-pause", &["k"]); // See issue #112 in GitHub repo - self.set_accels_for_action("win.cancel-record", &["c"]); + self.set_accels_for_action("win.cancel-recording", &["c"]); } } diff --git a/src/window.rs b/src/window.rs index 392fb265..4cb3ba29 100644 --- a/src/window.rs +++ b/src/window.rs @@ -77,8 +77,8 @@ mod imp { } }); - klass.install_action_async("win.toggle-record", None, |obj, _, _| async move { - if let Err(err) = obj.toggle_record().await { + klass.install_action_async("win.toggle-recording", None, |obj, _, _| async move { + if let Err(err) = obj.toggle_recording().await { if err.is::() { tracing::debug!("Recording cancelled: {:?}", err); } else { @@ -248,7 +248,7 @@ impl Window { Ok(()) } - async fn toggle_record(&self) -> Result<()> { + async fn toggle_recording(&self) -> Result<()> { let imp = self.imp(); match imp.pipeline.recording_state() { From 934f199698f8d25b015c2792991cbc3f2798a27a Mon Sep 17 00:00:00 2001 From: Dave Patrick Caberto Date: Tue, 28 Nov 2023 20:35:30 +0800 Subject: [PATCH 60/77] misc: use after on signal connection --- src/pipeline.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pipeline.rs b/src/pipeline.rs index 4a06cc55..a5d623f9 100644 --- a/src/pipeline.rs +++ b/src/pipeline.rs @@ -191,7 +191,7 @@ impl Pipeline { { self.connect_closure( "desktop-audio-peak", - true, + false, closure_local!(|obj: &Self, peaks: &Peaks| { f(obj, peaks); }), @@ -204,7 +204,7 @@ impl Pipeline { { self.connect_closure( "microphone-peak", - true, + false, closure_local!(|obj: &Self, peaks: &Peaks| { f(obj, peaks); }), From df54ff131cafb7edf2c73f7a6012c1798a065e4f Mon Sep 17 00:00:00 2001 From: Dave Patrick Caberto Date: Tue, 28 Nov 2023 20:39:06 +0800 Subject: [PATCH 61/77] misc: add todo on wiring up delay to UI --- src/window.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/window.rs b/src/window.rs index 4cb3ba29..e2c14087 100644 --- a/src/window.rs +++ b/src/window.rs @@ -263,7 +263,8 @@ impl Window { let settings = app.settings(); let timer = Timer::new(settings.record_delay(), |secs_left| { - println!("secs_left: {}", secs_left); + // TODO wire up to the UI + tracing::debug!("secs_left: {}", secs_left); }); imp.timer.replace(Some(timer.clone())); self.update_recording_ui(); From b072c7dc5f5e9ad5671762d74b03f0227ddf0c8a Mon Sep 17 00:00:00 2001 From: Dave Patrick Caberto Date: Tue, 28 Nov 2023 20:40:08 +0800 Subject: [PATCH 62/77] refactor: rename timer to delay timer --- src/window.rs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/window.rs b/src/window.rs index e2c14087..63253e3a 100644 --- a/src/window.rs +++ b/src/window.rs @@ -50,7 +50,7 @@ mod imp { pub(super) info_label: TemplateChild, pub(super) pipeline: Pipeline, - pub(super) timer: RefCell>, + pub(super) delay_timer: RefCell>, pub(super) session: RefCell>, pub(super) prev_selection: Cell>, } @@ -253,8 +253,8 @@ impl Window { match imp.pipeline.recording_state() { RecordingState::Idle => { - if let Some(timer) = imp.timer.take() { - timer.cancel(); + if let Some(delay_timer) = imp.delay_timer.take() { + delay_timer.cancel(); self.update_recording_ui(); return Ok(()); } @@ -262,16 +262,16 @@ impl Window { let app = utils::app_instance(); let settings = app.settings(); - let timer = Timer::new(settings.record_delay(), |secs_left| { + let delay_timer = Timer::new(settings.record_delay(), |secs_left| { // TODO wire up to the UI tracing::debug!("secs_left: {}", secs_left); }); - imp.timer.replace(Some(timer.clone())); + imp.delay_timer.replace(Some(delay_timer.clone())); self.update_recording_ui(); - timer.await?; + delay_timer.await?; - let _ = imp.timer.take(); + let _ = imp.delay_timer.take(); self.update_recording_ui(); self.start_recording() @@ -477,7 +477,7 @@ impl Window { match imp.pipeline.recording_state() { RecordingState::Idle => { - if imp.timer.borrow().is_some() { + if imp.delay_timer.borrow().is_some() { imp.record_button.set_label(&gettext("Cancel")); imp.record_button.remove_css_class("suggested-action"); From bcabb34049608a3e48f34583ced0fa99d026cfd7 Mon Sep 17 00:00:00 2001 From: Dave Patrick Caberto Date: Tue, 28 Nov 2023 20:43:38 +0800 Subject: [PATCH 63/77] refactor: use is_zero --- src/timer.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/timer.rs b/src/timer.rs index 2afbe8ff..f87fa98a 100644 --- a/src/timer.rs +++ b/src/timer.rs @@ -127,7 +127,7 @@ impl Future for Timer { Poll::Pending => {} } - if self.inner.duration == Duration::ZERO { + if self.inner.duration.is_zero() { self.inner.state.set(State::Done); return Poll::Ready(Ok(())); } From 562d0f4cee6cd82d706f365d1abb5abfbfa1d6bf Mon Sep 17 00:00:00 2001 From: Dave Patrick Caberto Date: Tue, 28 Nov 2023 20:50:28 +0800 Subject: [PATCH 64/77] misc: improve timer docs --- src/timer.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/timer.rs b/src/timer.rs index f87fa98a..5c79d45a 100644 --- a/src/timer.rs +++ b/src/timer.rs @@ -15,7 +15,9 @@ use crate::cancelled::Cancelled; const DEFAULT_SECS_LEFT_UPDATE_INTERVAL: Duration = Duration::from_millis(200); -/// Reference counted cancellable timer future +/// A reference counted cancellable timed future +/// +/// The timer will only start when it gets polled. #[derive(Clone)] pub struct Timer { inner: Rc, From 3cfb90660ada231bf08a51848dcd3515bacb5a3a Mon Sep 17 00:00:00 2001 From: Dave Patrick Caberto Date: Tue, 28 Nov 2023 20:52:39 +0800 Subject: [PATCH 65/77] po: remove removed files --- po/POTFILES.in | 3 --- 1 file changed, 3 deletions(-) diff --git a/po/POTFILES.in b/po/POTFILES.in index 38a52158..11d2cb48 100644 --- a/po/POTFILES.in +++ b/po/POTFILES.in @@ -1,17 +1,14 @@ data/io.github.seadve.Kooha.desktop.in.in data/io.github.seadve.Kooha.gschema.xml.in data/io.github.seadve.Kooha.metainfo.xml.in.in -data/resources/ui/area-selector.ui data/resources/ui/preferences-window.ui data/resources/ui/shortcuts.ui data/resources/ui/window.ui src/about.rs src/application.rs -src/area_selector/mod.rs src/audio_device.rs src/main.rs src/preferences_window.rs src/profile.rs -src/recording.rs src/settings.rs src/window.rs From d2fa978b7ab1d7db0af0276449e9a4113ed18d50 Mon Sep 17 00:00:00 2001 From: Dave Patrick Caberto Date: Tue, 28 Nov 2023 20:57:54 +0800 Subject: [PATCH 66/77] misc: update copyright --- src/about.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/about.rs b/src/about.rs index 5c7d6c87..8d9b6b50 100644 --- a/src/about.rs +++ b/src/about.rs @@ -16,7 +16,7 @@ pub fn present_window(transient_for: Option<&impl IsA>) { .application_name(gettext("Kooha")) .developer_name(gettext("Dave Patrick Caberto")) .version(VERSION) - .copyright(gettext("© 2022 Dave Patrick Caberto")) + .copyright(gettext("© 2023 Dave Patrick Caberto")) .license_type(gtk::License::Gpl30) .developers(vec![ "Dave Patrick Caberto", From b58ea3f726ed50d60e1c723dcab6142048b1b597 Mon Sep 17 00:00:00 2001 From: Dave Patrick Caberto Date: Wed, 29 Nov 2023 17:57:30 +0800 Subject: [PATCH 67/77] misc: new selection styling Use dashes --- Cargo.toml | 2 +- src/view_port.rs | 26 ++++++++++++++------------ 2 files changed, 15 insertions(+), 13 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 4c52b5f8..52e70c10 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,7 +13,7 @@ anyhow = "1.0.59" tracing = "0.1.36" tracing-subscriber = "0.3.15" gettext-rs = { version = "0.7.0", features = ["gettext-system"] } -gtk = { package = "gtk4", version = "0.7", features = ["gnome_45"] } +gtk = { package = "gtk4", version = "0.7", features = ["v4_14"] } gdk-wayland = { package = "gdk4-wayland", version = "0.7" } gdk-x11 = { package = "gdk4-x11", version = "0.7" } adw = { package = "libadwaita", version = "0.5", features = ["v1_4"] } diff --git a/src/view_port.rs b/src/view_port.rs index 4469c69b..fef58c11 100644 --- a/src/view_port.rs +++ b/src/view_port.rs @@ -24,10 +24,11 @@ const DEFAULT_SIZE: f64 = 100.0; const SHADE_COLOR: gdk::RGBA = gdk::RGBA::new(0.0, 0.0, 0.0, 0.5); -const SELECTION_COLOR: gdk::RGBA = gdk::RGBA::WHITE; +const SELECTION_LINE_WIDTH: f32 = 2.0; +const SELECTION_LINE_COLOR: gdk::RGBA = gdk::RGBA::WHITE.with_alpha(0.6); +const SELECTION_HANDLE_COLOR: gdk::RGBA = gdk::RGBA::WHITE; const SELECTION_HANDLE_SHADOW_COLOR: gdk::RGBA = gdk::RGBA::new(0.0, 0.0, 0.0, 0.2); const SELECTION_HANDLE_RADIUS: f32 = 12.0; -const SELECTION_LINE_WIDTH: f32 = 2.0; #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] enum CursorType { @@ -277,26 +278,27 @@ mod imp { } if let Some(selection) = obj.selection() { - let selection_rect = selection.rect(); - - // Outset so the displayed selection is never zero sized, avoiding flickering. - let selection_rect_display = selection_rect.inset_r(-1.0, -1.0); + let selection_rect = selection.rect().round_extents(); // Shades the area outside the selection. if let Some(paintable_rect) = obj.paintable_rect() { snapshot.push_mask(gsk::MaskMode::InvertedAlpha); - snapshot.append_color(&gdk::RGBA::BLACK, &selection_rect_display); + snapshot.append_color(&gdk::RGBA::BLACK, &selection_rect.inset_r(3.0, 3.0)); snapshot.pop(); snapshot.append_color(&SHADE_COLOR, &paintable_rect); snapshot.pop(); } - snapshot.append_border( - &RoundedRect::from_rect(selection_rect_display, 0.0), - &[SELECTION_LINE_WIDTH; 4], - &[SELECTION_COLOR; 4], + let path_builder = gsk::PathBuilder::new(); + path_builder.add_rect(&selection_rect); + snapshot.append_stroke( + &path_builder.to_path(), + &gsk::Stroke::builder(SELECTION_LINE_WIDTH) + .dash(&[10.0, 6.0]) + .build(), + &SELECTION_LINE_COLOR, ); for handle in self.selection_handles.get().unwrap() { @@ -310,7 +312,7 @@ mod imp { 3.0, ); snapshot.push_rounded_clip(&bounds); - snapshot.append_color(&SELECTION_COLOR, &handle); + snapshot.append_color(&SELECTION_HANDLE_COLOR, &handle); snapshot.pop(); } } From 797e0d9b7b2dda7169dc3ba8f5b9797e6891e01f Mon Sep 17 00:00:00 2001 From: Dave Patrick Caberto Date: Wed, 29 Nov 2023 18:18:19 +0800 Subject: [PATCH 68/77] fix: correct the crops --- src/pipeline.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pipeline.rs b/src/pipeline.rs index a5d623f9..6b0fd778 100644 --- a/src/pipeline.rs +++ b/src/pipeline.rs @@ -748,8 +748,8 @@ fn videocrop_compute(data: &CropData, stream_size: StreamSize) -> Result Date: Wed, 29 Nov 2023 18:25:01 +0800 Subject: [PATCH 69/77] misc: round selection handle extents --- src/view_port.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/view_port.rs b/src/view_port.rs index fef58c11..b6851279 100644 --- a/src/view_port.rs +++ b/src/view_port.rs @@ -311,6 +311,7 @@ mod imp { 2.0, 3.0, ); + snapshot.push_rounded_clip(&bounds); snapshot.append_color(&SELECTION_HANDLE_COLOR, &handle); snapshot.pop(); @@ -472,8 +473,12 @@ impl ViewPort { selection_handle_diameter, ); - imp.selection_handles - .set(Some([top_left, top_right, bottom_right, bottom_left])); + imp.selection_handles.set(Some([ + top_left.round_extents(), + top_right.round_extents(), + bottom_right.round_extents(), + bottom_left.round_extents(), + ])); } } From 021b0b01054852bddb84364f9a85fa0f1720ea84 Mon Sep 17 00:00:00 2001 From: Dave Patrick Caberto Date: Wed, 29 Nov 2023 18:29:59 +0800 Subject: [PATCH 70/77] refactor: handle select saving location error outside --- src/preferences_window.rs | 23 ++++++++++++++--------- src/settings.rs | 14 +++----------- 2 files changed, 17 insertions(+), 20 deletions(-) diff --git a/src/preferences_window.rs b/src/preferences_window.rs index 58ecee6f..02eba8b2 100644 --- a/src/preferences_window.rs +++ b/src/preferences_window.rs @@ -52,15 +52,20 @@ mod imp { |obj, _, _| async move { if let Err(err) = obj.settings().select_saving_location(&obj).await { tracing::error!("Failed to select saving location: {:?}", err); - let dialog = adw::MessageDialog::builder() - .heading(gettext("Failed to select saving location")) - .body(err.to_string()) - .default_response("ok") - .modal(true) - .build(); - dialog.add_response("ok", &gettext("Ok")); - dialog.set_transient_for(Some(&obj)); - dialog.present(); + if !err + .downcast_ref::() + .is_some_and(|error| error.matches(gtk::DialogError::Dismissed)) + { + let dialog = adw::MessageDialog::builder() + .heading(gettext("Failed to select saving location")) + .body(err.to_string()) + .default_response("ok") + .modal(true) + .build(); + dialog.add_response("ok", &gettext("Ok")); + dialog.set_transient_for(Some(&obj)); + dialog.present(); + } } }, ); diff --git a/src/settings.rs b/src/settings.rs index 8582a8ab..aa4676c2 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -38,17 +38,9 @@ impl Settings { .initial_folder(&gio::File::for_path(self.saving_location())) .build(); - match dialog.select_folder_future(Some(transient_for)).await { - Ok(folder) => { - let path = folder.path().context("Folder does not have a path")?; - self.0.set("saving-location", path).unwrap(); - } - Err(err) => { - if !err.matches(gtk::DialogError::Dismissed) { - return Err(err.into()); - } - } - } + let folder = dialog.select_folder_future(Some(transient_for)).await?; + let path = folder.path().context("Folder does not have a path")?; + self.0.set("saving-location", path).unwrap(); Ok(()) } From ab48ad9010edc3eb7d608937f897dc65bf355dcc Mon Sep 17 00:00:00 2001 From: Dave Patrick Caberto Date: Wed, 29 Nov 2023 18:33:06 +0800 Subject: [PATCH 71/77] misc: use toast to show preferences window errors --- src/preferences_window.rs | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/src/preferences_window.rs b/src/preferences_window.rs index 02eba8b2..b9d2ea20 100644 --- a/src/preferences_window.rs +++ b/src/preferences_window.rs @@ -56,15 +56,7 @@ mod imp { .downcast_ref::() .is_some_and(|error| error.matches(gtk::DialogError::Dismissed)) { - let dialog = adw::MessageDialog::builder() - .heading(gettext("Failed to select saving location")) - .body(err.to_string()) - .default_response("ok") - .modal(true) - .build(); - dialog.add_response("ok", &gettext("Ok")); - dialog.set_transient_for(Some(&obj)); - dialog.present(); + obj.add_message_toast(&gettext("Failed to set saving location")); } } }, @@ -173,6 +165,11 @@ impl PreferencesWindow { .build() } + fn add_message_toast(&self, message: &str) { + let toast = adw::Toast::new(message); + self.add_toast(toast); + } + fn update_file_chooser_button(&self) { let saving_location_display = self.settings().saving_location().display().to_string(); From 89ed4323f03b2365882d94d954bcbbece1d66766 Mon Sep 17 00:00:00 2001 From: Dave Patrick Caberto Date: Wed, 29 Nov 2023 18:44:06 +0800 Subject: [PATCH 72/77] refactor: use imp where applicable --- src/window.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/window.rs b/src/window.rs index 63253e3a..ab31265a 100644 --- a/src/window.rs +++ b/src/window.rs @@ -110,7 +110,7 @@ mod imp { .connect_active_notify(clone!(@weak obj => move |toggle| { let imp = obj.imp(); if toggle.is_active() { - let selection = obj.imp().prev_selection.get().unwrap_or_else(|| { + let selection = imp.prev_selection.get().unwrap_or_else(|| { let mid_x = imp.view_port.width() as f32 / 2.0; let mid_y = imp.view_port.height() as f32 / 2.0; let offset = 20.0 * imp.view_port.scale_factor() as f32; From 8d47ec5f5c14938814d70fa627d9a0309bb30097 Mon Sep 17 00:00:00 2001 From: Dave Patrick Caberto Date: Wed, 29 Nov 2023 18:50:31 +0800 Subject: [PATCH 73/77] misc: drop useless connect_paintable_notify It only get called once and only on window construct. Also update selection toggle sensitivity as it now depends on the stream size --- src/window.rs | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/window.rs b/src/window.rs index ab31265a..a33444d7 100644 --- a/src/window.rs +++ b/src/window.rs @@ -126,11 +126,6 @@ mod imp { imp.view_port.set_selection(None::); } })); - self.view_port - .connect_paintable_notify(clone!(@weak obj => move |_| { - obj.update_selection_toggle_sensitivity(); - obj.update_info_label(); - })); self.view_port .connect_selection_notify(clone!(@weak obj => move |view_port| { if let Some(selection) = view_port.selection() { @@ -142,6 +137,7 @@ mod imp { self.pipeline .connect_stream_size_notify(clone!(@weak obj => move |_| { + obj.update_selection_toggle_sensitivity(); obj.update_info_label(); })); self.pipeline @@ -428,7 +424,7 @@ impl Window { let imp = self.imp(); imp.selection_toggle - .set_sensitive(imp.view_port.paintable().is_some()); + .set_sensitive(imp.pipeline.stream_size().is_some()); } fn update_selection_toggle(&self) { From 15851daffa8877d0dd2dc8be3086112e6e638ac1 Mon Sep 17 00:00:00 2001 From: Dave Patrick Caberto Date: Wed, 29 Nov 2023 19:15:48 +0800 Subject: [PATCH 74/77] fix: properly get stream size --- src/pipeline.rs | 61 +++++++++++++++++++++++-------------------------- 1 file changed, 29 insertions(+), 32 deletions(-) diff --git a/src/pipeline.rs b/src/pipeline.rs index 6b0fd778..be28a494 100644 --- a/src/pipeline.rs +++ b/src/pipeline.rs @@ -127,6 +127,7 @@ mod imp { pub(super) recording_elements: RefCell>, pub(super) duration_source_id: RefCell>, + pub(super) caps_notify_source_id: RefCell>, } #[glib::object_subclass] @@ -156,6 +157,10 @@ mod imp { source_id.remove(); } + if let Some(source_id) = self.caps_notify_source_id.take() { + source_id.remove(); + } + let _ = self.bus_watch_guard.take(); } @@ -432,8 +437,6 @@ impl Pipeline { element.sync_state_with_parent()?; } - self.set_stream_size(None); - tracing::debug!("Loaded {} streams", streams.len()); match imp.inner.set_state(gst::State::Playing)? { @@ -556,17 +559,6 @@ impl Pipeline { Ok(()) } - fn set_stream_size(&self, stream_size: Option) { - let imp = self.imp(); - - if stream_size == imp.stream_size.get() { - return; - } - - imp.stream_size.set(stream_size); - self.notify_stream_size(); - } - fn set_recording_state(&self, recording_state: RecordingState) { let imp = self.imp(); @@ -582,15 +574,6 @@ impl Pipeline { let imp = self.imp(); match message.view() { - gst::MessageView::AsyncDone(_) => { - tracing::debug!("Async done message from bus"); - - if imp.stream_size.get().is_none() { - self.update_stream_size(); - } - - glib::ControlFlow::Continue - } gst::MessageView::Element(e) => { tracing::trace!(?message, "Element message from bus"); @@ -666,16 +649,15 @@ impl Pipeline { let imp = self.imp(); let compositor = imp.inner.by_name(COMPOSITOR_NAME).unwrap(); - let caps = compositor - .static_pad("src") - .unwrap() - .current_caps() - .unwrap(); - let caps_struct = caps.structure(0).unwrap(); - let stream_width = caps_struct.get::("width").unwrap(); - let stream_height = caps_struct.get::("height").unwrap(); + let stream_size = compositor.static_pad("src").unwrap().caps().map(|caps| { + let caps_struct = caps.structure(0).unwrap(); + let stream_width = caps_struct.get::("width").unwrap(); + let stream_height = caps_struct.get::("height").unwrap(); + StreamSize::new(stream_width, stream_height) + }); - self.set_stream_size(Some(StreamSize::new(stream_width, stream_height))); + imp.stream_size.set(stream_size); + self.notify_stream_size(); } fn setup_elements(&self) -> Result<()> { @@ -710,9 +692,24 @@ impl Pipeline { obj.handle_bus_message(message) }), )?; - imp.bus_watch_guard.replace(Some(bus_watch_guard)); + let (tx, rx) = glib::MainContext::channel(glib::Priority::DEFAULT); + compositor + .static_pad("src") + .unwrap() + .connect_caps_notify(move |_| { + tx.send(()).unwrap(); + }); + let source_id = rx.attach( + None, + clone!(@weak self as obj => @default-panic, move |_| { + obj.update_stream_size(); + glib::ControlFlow::Continue + }), + ); + imp.caps_notify_source_id.replace(Some(source_id)); + Ok(()) } } From c21134229eeefbf67eb0e4e6ca7ac133e17d5eae Mon Sep 17 00:00:00 2001 From: Dave Patrick Caberto Date: Thu, 30 Nov 2023 21:27:39 +0800 Subject: [PATCH 75/77] misc: update todo --- src/view_port.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/view_port.rs b/src/view_port.rs index b6851279..0c812d34 100644 --- a/src/view_port.rs +++ b/src/view_port.rs @@ -19,6 +19,7 @@ use std::{ // * Handle selection outside paintable rect, when setting selection. // * Add animation when entering/leaving selection mode. // * Add undo and redo. +// * Add minimum selection size. const DEFAULT_SIZE: f64 = 100.0; From 433773e4d730e9fe809951da0b1536cf5c1d94af Mon Sep 17 00:00:00 2001 From: Dave Patrick Caberto Date: Sun, 3 Dec 2023 11:17:00 +0800 Subject: [PATCH 76/77] misc: dump pipeline data to dot file when debug mode on record --- src/pipeline.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/pipeline.rs b/src/pipeline.rs index be28a494..c37d6034 100644 --- a/src/pipeline.rs +++ b/src/pipeline.rs @@ -355,6 +355,17 @@ impl Pipeline { self.set_recording_state(RecordingState::started(gst::ClockTime::ZERO)); + if tracing::enabled!(tracing::Level::DEBUG) { + std::fs::write( + glib::DateTime::now_local() + .unwrap() + .format("kooha-%F-%H-%M-%S.dot") + .unwrap(), + gst::debug_bin_to_dot_data(&imp.inner, gst::DebugGraphDetails::VERBOSE), + ) + .unwrap(); + } + tracing::debug!("Started recording"); Ok(()) From f65f921b869ee5d126a86590b0783cf1111e06c5 Mon Sep 17 00:00:00 2001 From: Dave Patrick Caberto Date: Mon, 4 Dec 2023 12:18:59 +0800 Subject: [PATCH 77/77] misc: use plain videoconvert for sink --- src/pipeline.rs | 17 +++-------------- 1 file changed, 3 insertions(+), 14 deletions(-) diff --git a/src/pipeline.rs b/src/pipeline.rs index c37d6034..ffbfa0a4 100644 --- a/src/pipeline.rs +++ b/src/pipeline.rs @@ -13,7 +13,6 @@ use gtk::{ use crate::{ audio_device::{self, Class as AudioDeviceClass}, screencast_session::Stream, - utils, }; const DURATION_UPDATE_INTERVAL: Duration = Duration::from_millis(200); @@ -677,26 +676,16 @@ impl Pipeline { let compositor = gst::ElementFactory::make("compositor") .name(COMPOSITOR_NAME) .build()?; - let convert = gst::ElementFactory::make("videoconvert") - .property("chroma-mode", gst_video::VideoChromaMode::None) - .property("dither", gst_video::VideoDitherMethod::None) - .property("matrix-mode", gst_video::VideoMatrixMode::OutputOnly) - .property("n-threads", utils::ideal_thread_count()) - .build()?; let tee = gst::ElementFactory::make("tee") .name(VIDEO_TEE_NAME) .build()?; + let convert = gst::ElementFactory::make("videoconvert").build()?; let sink = gst::ElementFactory::make("gtk4paintablesink") .name(PAINTABLE_SINK_NAME) .build()?; - imp.inner.add_many([&compositor, &convert, &tee, &sink])?; - gst::Element::link_many([&compositor, &convert, &tee])?; - - let tee_src_pad = tee - .request_pad_simple("src_%u") - .context("Failed to request sink_%u pad from compositor")?; - tee_src_pad.link(&sink.static_pad("sink").unwrap())?; + imp.inner.add_many([&compositor, &tee, &convert, &sink])?; + gst::Element::link_many([&compositor, &tee, &convert, &sink])?; let bus_watch_guard = imp.inner.bus().unwrap().add_watch_local( clone!(@weak self as obj => @default-panic, move |_, message| {