diff --git a/Cargo.lock b/Cargo.lock index bdafe9af..9e85a35a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -954,6 +954,8 @@ dependencies = [ "libadwaita", "libpulse-binding", "libpulse-glib-binding", + "num-rational", + "num-traits", "once_cell", "serde", "serde_yaml", diff --git a/Cargo.toml b/Cargo.toml index d5fb0c3f..268cff54 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,8 @@ gst = { package = "gstreamer", version = "0.22" } gst-plugin-gif = "0.12" gst-plugin-gtk4 = { version = "0.12", features = ["gtk_v4_14"] } gtk = { package = "gtk4", version = "0.8", features = ["v4_14"] } +num-rational = { version = "0.4", default-features = false } +num-traits = "0.2" once_cell = "1.19.0" pulse = { package = "libpulse-binding", version = "2.26.0" } pulse_glib = { package = "libpulse-glib-binding", version = "2.25.1" } diff --git a/data/io.github.seadve.Kooha.gschema.xml.in b/data/io.github.seadve.Kooha.gschema.xml.in index 972b2df8..52b04acb 100644 --- a/data/io.github.seadve.Kooha.gschema.xml.in +++ b/data/io.github.seadve.Kooha.gschema.xml.in @@ -26,8 +26,8 @@ b"" - - 30 + + (30, 1) "" diff --git a/data/resources/resources.gresource.xml b/data/resources/resources.gresource.xml index 4811cc1c..7099baa2 100644 --- a/data/resources/resources.gresource.xml +++ b/data/resources/resources.gresource.xml @@ -15,6 +15,7 @@ profiles.yml style.css ui/area-selector.ui + ui/item_row.ui ui/preferences-dialog.ui ui/shortcuts.ui ui/view-port.ui diff --git a/data/resources/ui/item_row.ui b/data/resources/ui/item_row.ui new file mode 100644 index 00000000..525afb7f --- /dev/null +++ b/data/resources/ui/item_row.ui @@ -0,0 +1,31 @@ + + + + diff --git a/data/resources/ui/preferences-dialog.ui b/data/resources/ui/preferences-dialog.ui index eaee9e5c..6d91b289 100644 --- a/data/resources/ui/preferences-dialog.ui +++ b/data/resources/ui/preferences-dialog.ui @@ -54,35 +54,8 @@ - + Frame Rate - - - 12 - - - This frame rate may cause performance issues. - warning-symbolic - - - - - - center - - - 0 - 120 - 1 - 5 - - - - - - diff --git a/src/area_selector/mod.rs b/src/area_selector/mod.rs index 0cf35927..f4ff2169 100644 --- a/src/area_selector/mod.rs +++ b/src/area_selector/mod.rs @@ -14,9 +14,14 @@ use std::{cell::RefCell, os::unix::prelude::RawFd}; pub use self::view_port::Selection; use self::view_port::ViewPort; -use crate::{application::Application, cancelled::Cancelled, pipeline, screencast_session::Stream}; +use crate::{ + application::Application, + cancelled::Cancelled, + pipeline::{self, Framerate}, + screencast_session::Stream, +}; -const PREVIEW_FRAMERATE: u32 = 60; +const PREVIEW_FRAMERATE: Framerate = Framerate::new_raw(60, 1); const WINDOW_TO_MONITOR_SCALE_FACTOR: f64 = 0.4; // We can't get header bar height before the window is presented, so we assume "46" as the default. diff --git a/src/framerate_option.rs b/src/framerate_option.rs new file mode 100644 index 00000000..2e34fc5f --- /dev/null +++ b/src/framerate_option.rs @@ -0,0 +1,144 @@ +use std::fmt; + +use gtk::{gio, glib::BoxedAnyObject}; +use num_traits::Signed; + +use crate::{pipeline::Framerate, settings::Settings}; + +/// The available options for the framerate. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FramerateOption { + _10, + _20, + _24, + _25, + _29_97, + _30, + _48, + _50, + _59_94, + _60, + Other(Framerate), +} + +impl FramerateOption { + fn all_except_other() -> [Self; 10] { + [ + Self::_10, + Self::_20, + Self::_24, + Self::_25, + Self::_29_97, + Self::_30, + Self::_48, + Self::_50, + Self::_59_94, + Self::_60, + ] + } + + /// Returns a model of type `BoxedAnyObject`. This contains `Other` if the current settings framerate + /// does not match any of the predefined options. + pub fn model(settings: &Settings) -> gio::ListStore { + let list_store = gio::ListStore::new::(); + + let items = Self::all_except_other() + .into_iter() + .map(BoxedAnyObject::new) + .collect::>(); + list_store.splice(0, 0, &items); + + if let other @ Self::Other(_) = Self::from_framerate(settings.framerate()) { + list_store.append(&BoxedAnyObject::new(other)); + } + + list_store + } + + /// Returns the corresponding `FramerateOption` for the given framerate. + pub fn from_framerate(framerate: Framerate) -> Self { + // This must be updated if an option is added or removed. + let epsilon = Framerate::new_raw(1, 100); + + Self::all_except_other() + .into_iter() + .find(|o| (o.as_framerate() - framerate).abs() < epsilon) + .unwrap_or(Self::Other(framerate)) + } + + /// Converts a `FramerateOption` to a framerate. + pub const fn as_framerate(self) -> Framerate { + let (numerator, denominator) = match self { + Self::_10 => (10, 1), + Self::_20 => (20, 1), + Self::_24 => (24, 1), + Self::_25 => (25, 1), + Self::_29_97 => (30_000, 1001), + Self::_30 => (30, 1), + Self::_48 => (48, 1), + Self::_50 => (50, 1), + Self::_59_94 => (60_000, 1001), + Self::_60 => (60, 1), + Self::Other(framerate) => return framerate, + }; + Framerate::new_raw(numerator, denominator) + } +} + +impl fmt::Display for FramerateOption { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + Self::_10 => "10", + Self::_20 => "20", + Self::_24 => "24", + Self::_25 => "25", + Self::_29_97 => "29.97", + Self::_30 => "30", + Self::_48 => "48", + Self::_50 => "50", + Self::_59_94 => "59.94", + Self::_60 => "60", + Self::Other(framerate) => return write!(f, "{}", framerate), + }) + } +} + +#[cfg(test)] +mod tests { + use crate::pipeline::Framerate; + + use super::*; + + #[track_caller] + fn test_framerate(framerate: Framerate, expected: FramerateOption) { + assert_eq!(FramerateOption::from_framerate(framerate), expected); + } + + #[test] + fn framerate_option() { + test_framerate( + Framerate::from_integer(5), + FramerateOption::Other(Framerate::from_integer(5)), + ); + test_framerate(Framerate::from_integer(10), FramerateOption::_10); + test_framerate(Framerate::from_integer(20), FramerateOption::_20); + test_framerate(Framerate::from_integer(24), FramerateOption::_24); + test_framerate(Framerate::from_integer(25), FramerateOption::_25); + test_framerate( + Framerate::approximate_float(29.97).unwrap(), + FramerateOption::_29_97, + ); + test_framerate(Framerate::from_integer(30), FramerateOption::_30); + test_framerate(Framerate::from_integer(48), FramerateOption::_48); + test_framerate(Framerate::from_integer(50), FramerateOption::_50); + test_framerate( + Framerate::approximate_float(59.94).unwrap(), + FramerateOption::_59_94, + ); + test_framerate(Framerate::from_integer(60), FramerateOption::_60); + test_framerate( + Framerate::from_integer(120), + FramerateOption::Other(Framerate::from_integer(120)), + ); + } +} diff --git a/src/item_row.rs b/src/item_row.rs new file mode 100644 index 00000000..c44afed6 --- /dev/null +++ b/src/item_row.rs @@ -0,0 +1,166 @@ +use gtk::{glib, prelude::*, subclass::prelude::*}; + +mod imp { + use std::cell::{Cell, RefCell}; + + use super::*; + + #[derive(Default, glib::Properties, gtk::CompositeTemplate)] + #[properties(wrapper_type = super::ItemRow)] + #[template(resource = "/io/github/seadve/Kooha/ui/item_row.ui")] + pub struct ItemRow { + #[property(get, set = Self::set_title, explicit_notify)] + pub(super) title: RefCell, + #[property(get, set = Self::set_warning_tooltip_text, explicit_notify)] + pub(super) warning_tooltip_text: RefCell, + #[property(get, set = Self::set_shows_warning_icon, explicit_notify)] + pub(super) shows_warning_icon: Cell, + #[property(get, set = Self::set_shows_selected_icon, explicit_notify)] + pub(super) shows_selected_icon: Cell, + #[property(get, set = Self::set_is_selected, explicit_notify)] + pub(super) is_selected: Cell, + + #[template_child] + pub(super) warning_icon: TemplateChild, + #[template_child] + pub(super) title_label: TemplateChild, + #[template_child] + pub(super) selected_icon: TemplateChild, + } + + #[glib::object_subclass] + impl ObjectSubclass for ItemRow { + const NAME: &'static str = "KoohaItemRow"; + type Type = super::ItemRow; + type ParentType = gtk::Widget; + + fn class_init(klass: &mut Self::Class) { + klass.bind_template(); + } + + fn instance_init(obj: &glib::subclass::InitializingObject) { + obj.init_template(); + } + } + + #[glib::derived_properties] + impl ObjectImpl for ItemRow { + fn constructed(&self) { + self.parent_constructed(); + + let obj = self.obj(); + + obj.update_title_label(); + obj.update_warning_icon_tooltip_text(); + obj.update_warning_icon_visibility(); + obj.update_selected_icon_visibility(); + obj.update_selected_icon_opacity(); + } + + fn dispose(&self) { + self.dispose_template(); + } + } + + impl WidgetImpl for ItemRow {} + + impl ItemRow { + fn set_title(&self, title: String) { + let obj = self.obj(); + + if title == obj.title() { + return; + } + + self.title.set(title); + obj.update_title_label(); + obj.notify_title(); + } + + fn set_warning_tooltip_text(&self, warning_tooltip_text: String) { + let obj = self.obj(); + + if warning_tooltip_text == obj.warning_tooltip_text() { + return; + } + + self.warning_tooltip_text.set(warning_tooltip_text); + obj.update_warning_icon_tooltip_text(); + obj.notify_warning_tooltip_text(); + } + + fn set_shows_warning_icon(&self, shows_warning_icon: bool) { + let obj = self.obj(); + + if shows_warning_icon == obj.shows_warning_icon() { + return; + } + + self.shows_warning_icon.set(shows_warning_icon); + obj.update_warning_icon_visibility(); + obj.notify_shows_warning_icon(); + } + + fn set_shows_selected_icon(&self, shows_selected_icon: bool) { + let obj = self.obj(); + + if shows_selected_icon == obj.shows_selected_icon() { + return; + } + + self.shows_selected_icon.set(shows_selected_icon); + obj.update_selected_icon_visibility(); + obj.notify_shows_selected_icon(); + } + + fn set_is_selected(&self, is_selected: bool) { + let obj = self.obj(); + + if is_selected == obj.is_selected() { + return; + } + + self.is_selected.set(is_selected); + obj.update_selected_icon_opacity(); + obj.notify_is_selected(); + } + } +} + +glib::wrapper! { + pub struct ItemRow(ObjectSubclass) + @extends gtk::Widget; +} + +impl ItemRow { + pub fn new() -> Self { + glib::Object::new() + } + + fn update_title_label(&self) { + let imp = self.imp(); + imp.title_label.set_label(&self.title()); + } + + fn update_warning_icon_tooltip_text(&self) { + let imp = self.imp(); + imp.warning_icon + .set_tooltip_text(Some(&self.warning_tooltip_text())); + } + + fn update_warning_icon_visibility(&self) { + let imp = self.imp(); + imp.warning_icon.set_visible(self.shows_warning_icon()); + } + + fn update_selected_icon_visibility(&self) { + let imp = self.imp(); + imp.selected_icon.set_visible(self.shows_selected_icon()); + } + + fn update_selected_icon_opacity(&self) { + let imp = self.imp(); + let opacity = if self.is_selected() { 1.0 } else { 0.0 }; + imp.selected_icon.set_opacity(opacity); + } +} diff --git a/src/main.rs b/src/main.rs index 3e5ca77e..b5b1b5b7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -30,8 +30,10 @@ mod audio_device; mod cancelled; mod config; mod format_time; +mod framerate_option; mod help; mod i18n; +mod item_row; mod pipeline; mod preferences_dialog; mod profile; diff --git a/src/pipeline.rs b/src/pipeline.rs index dbe71241..0da4347b 100644 --- a/src/pipeline.rs +++ b/src/pipeline.rs @@ -1,6 +1,7 @@ use anyhow::{bail, Context, Ok, Result}; use gst::prelude::*; use gtk::graphene::Rect; +use num_rational::Rational32; use std::{ os::unix::io::RawFd, @@ -12,11 +13,13 @@ use crate::{area_selector::SelectAreaData, profile::Profile, screencast_session: const AUDIO_SAMPLE_RATE: i32 = 48_000; const AUDIO_N_CHANNELS: i32 = 1; +pub type Framerate = Rational32; + #[derive(Debug)] #[must_use] pub struct PipelineBuilder { file_path: PathBuf, - framerate: u32, + framerate: Framerate, profile: Profile, fd: RawFd, streams: Vec, @@ -28,7 +31,7 @@ pub struct PipelineBuilder { impl PipelineBuilder { pub fn new( file_path: &Path, - framerate: u32, + framerate: Framerate, profile: Profile, fd: RawFd, streams: Vec, @@ -63,7 +66,7 @@ impl PipelineBuilder { pub fn build(&self) -> Result { tracing::debug!( file_path = %self.file_path.display(), - framerate = self.framerate, + framerate = ?self.framerate, profile = ?self.profile.id(), stream_len = self.streams.len(), streams = ?self.streams, @@ -226,13 +229,13 @@ fn make_videocrop(data: &SelectAreaData) -> Result { pub fn make_pipewiresrc_bin( fd: RawFd, streams: &[Stream], - framerate: u32, + framerate: Framerate, select_area_data: Option<&SelectAreaData>, ) -> Result { let bin = gst::Bin::builder().name("kooha-pipewiresrc-bin").build(); let videorate_caps = gst::Caps::builder("video/x-raw") - .field("framerate", gst::Fraction::new(framerate as i32, 1)) + .field("framerate", gst::Fraction::from(framerate)) .build(); let src_element = match streams { diff --git a/src/preferences_dialog.rs b/src/preferences_dialog.rs index 82528d3f..7d22c8a6 100644 --- a/src/preferences_dialog.rs +++ b/src/preferences_dialog.rs @@ -5,10 +5,15 @@ use gettextrs::gettext; use gtk::{ gio, glib::{self, clone, closure, BoxedAnyObject}, - pango, }; -use crate::{profile::Profile, settings::Settings, IS_EXPERIMENTAL_MODE}; +use crate::{ + framerate_option::FramerateOption, item_row::ItemRow, profile::Profile, settings::Settings, + IS_EXPERIMENTAL_MODE, +}; + +const ROW_SELECTED_ITEM_NOTIFY_HANDLER_ID_KEY: &str = "kooha-row-selected-item-notify-handler-id"; +const SETTINGS_PROFILE_CHANGED_HANDLER_ID_KEY: &str = "kooha-settings-profile-changed-handler-id"; /// Used to represent "none" profile in the profiles model type NoneProfile = BoxedAnyObject; @@ -26,13 +31,11 @@ mod imp { #[property(get, set, construct_only)] pub(super) settings: OnceCell, - #[template_child] - pub(super) framerate_button: TemplateChild, - #[template_child] - pub(super) framerate_warning: TemplateChild, #[template_child] pub(super) profile_row: TemplateChild, #[template_child] + pub(super) framerate_row: TemplateChild, + #[template_child] pub(super) delay_button: TemplateChild, #[template_child] pub(super) file_chooser_button_content: TemplateChild, @@ -81,16 +84,68 @@ mod imp { let obj = self.obj(); let settings = obj.settings(); - let active_profile = settings.profile(); - self.profile_row - .set_factory(Some(&profile_row_factory(&self.profile_row, false))); - self.profile_row - .set_list_factory(Some(&profile_row_factory(&self.profile_row, true))); + self.framerate_row.set_factory(Some(&row_factory( + &self.framerate_row, + &gettext("This frame rate may cause performance issues on the selected format."), + clone!(@strong settings => move |list_item| { + let item = list_item.item().unwrap(); + let item_row = list_item.child().unwrap().downcast::().unwrap(); + + let framerate_option = item + .downcast_ref::() + .unwrap() + .borrow::(); + item_row.set_title(framerate_option.to_string()); + + unsafe { + list_item.set_data( + SETTINGS_PROFILE_CHANGED_HANDLER_ID_KEY, + settings.connect_profile_changed( + clone!(@weak list_item => move |settings| { + update_framerate_row_shows_warning_icon(settings, &list_item); + }), + ), + ); + } + + update_framerate_row_shows_warning_icon(&settings, list_item); + }), + clone!(@strong settings => move |list_item| { + unsafe { + let handler_id = list_item + .steal_data(SETTINGS_PROFILE_CHANGED_HANDLER_ID_KEY) + .unwrap(); + settings.disconnect(handler_id); + } + }), + ))); + self.framerate_row + .set_model(Some(&FramerateOption::model(&settings))); + + self.profile_row.set_factory(Some(&row_factory( + &self.profile_row, + &gettext(gettext("This format is experimental and unsupported.")), + |list_item| { + let item = list_item.item().unwrap(); + let item_row = list_item.child().unwrap().downcast::().unwrap(); + + let profile = profile_from_obj(&item); + item_row.set_title( + profile + .map_or_else(|| gettext("None"), |profile| profile.name().to_string()), + ); + item_row.set_shows_warning_icon( + profile.is_some_and(|profile| profile.is_experimental()), + ); + }, + |_| {}, + ))); let profiles = Profile::all() .inspect_err(|err| tracing::error!("Failed to load profiles: {:?}", err)) .unwrap_or_default(); let profiles_model = gio::ListStore::new::(); + let active_profile = settings.profile(); if active_profile.is_none() { profiles_model.append(&NoneProfile::new(())); } @@ -116,12 +171,8 @@ mod imp { .bind_record_delay(&self.delay_button.get(), "value") .build(); - settings - .bind_video_framerate(&self.framerate_button.get(), "value") - .build(); - - settings.connect_video_framerate_changed(clone!(@weak obj => move |_| { - obj.update_framerate_warning(); + settings.connect_framerate_changed(clone!(@weak obj => move |_| { + obj.update_framerate_row(); })); settings.connect_saving_location_changed(clone!(@weak obj => move |_| { @@ -130,15 +181,14 @@ mod imp { settings.connect_profile_changed(clone!(@weak obj => move |_| { obj.update_profile_row(); - obj.update_framerate_warning(); })); obj.update_file_chooser_button(); - obj.update_framerate_warning(); obj.update_profile_row(); + obj.update_framerate_row(); - // Load last active profile first in `update_profile_row` before - // connecting to the signal to avoid unnecessary updates. + // Load last active value first in `update_*_row` before connecting to + // the signal to avoid unnecessary updates. self.profile_row .connect_selected_item_notify(clone!(@weak obj => move |row| { if let Some(item) = row.selected_item() { @@ -146,6 +196,17 @@ mod imp { obj.settings().set_profile(profile); } })); + self.framerate_row + .connect_selected_item_notify(clone!(@weak obj => move |row| { + if let Some(item) = row.selected_item() { + let framerate_option = item + .downcast_ref::() + .unwrap() + .borrow::(); + obj.settings() + .set_framerate(framerate_option.as_framerate()); + } + })); } } @@ -175,23 +236,18 @@ impl PreferencesDialog { } fn update_profile_row(&self) { + let imp = self.imp(); + let settings = self.settings(); let active_profile = settings.profile(); - let imp = self.imp(); - let position = imp - .profile_row - .model() - .unwrap() - .into_iter() - .position( - |item| match (profile_from_obj(&item.unwrap()), &active_profile) { - (Some(profile), Some(active_profile)) => profile.id() == active_profile.id(), - (None, None) => true, - _ => false, - }, - ); - + let position = imp.profile_row.model().unwrap().iter().position(|item| { + match (profile_from_obj(&item.unwrap()), &active_profile) { + (Some(profile), Some(active_profile)) => profile.id() == active_profile.id(), + (None, None) => true, + _ => false, + } + }); if let Some(position) = position { imp.profile_row.set_selected(position as u32); } else { @@ -202,91 +258,116 @@ impl PreferencesDialog { } } - fn update_framerate_warning(&self) { + fn update_framerate_row(&self) { let imp = self.imp(); + let settings = self.settings(); + let framerate_option = FramerateOption::from_framerate(settings.framerate()); - imp.framerate_warning.set_visible( - settings.profile().is_some_and(|profile| { - settings.video_framerate() > profile.suggested_max_framerate() - }), - ); + let position = imp + .framerate_row + .model() + .unwrap() + .iter::() + .position(|item| { + let item = item.unwrap(); + let o = item.borrow::(); + *o == framerate_option + }); + if let Some(position) = position { + imp.framerate_row.set_selected(position as u32); + } else { + tracing::error!( + "Active framerate `{:?}` was not found on framerate model", + framerate_option + ); + } } } -fn profile_row_factory( - profile_row: &adw::ComboRow, - show_selected_indicator: bool, +fn row_factory( + row: &adw::ComboRow, + warning_tooltip_text: &str, + bind_cb: impl Fn(>k::ListItem) + 'static, + unbind_cb: impl Fn(>k::ListItem) + 'static, ) -> gtk::SignalListItemFactory { let factory = gtk::SignalListItemFactory::new(); - factory.connect_setup(clone!(@weak profile_row => move |_, list_item| { + + let warning_tooltip_text = warning_tooltip_text.to_string(); + factory.connect_setup(clone!(@weak row => move |_, list_item| { let list_item = list_item.downcast_ref::().unwrap(); - let item_expression = list_item.property_expression("item"); - - let hbox = gtk::Box::builder().spacing(12).build(); - - let warning_indicator = gtk::Image::builder() - .tooltip_text(gettext("This format is experimental and unsupported.")) - .icon_name("warning-symbolic") - .build(); - warning_indicator.add_css_class("warning"); - hbox.append(&warning_indicator); - - item_expression - .chain_closure::(closure!( - |_: Option, obj: Option| { - obj.as_ref() - .and_then(|obj| profile_from_obj(obj)) - .is_some_and(|profile| profile.is_experimental()) - } - )) - .bind(&warning_indicator, "visible", glib::Object::NONE); - - let label = gtk::Label::builder() - .valign(gtk::Align::Center) - .xalign(0.0) - .ellipsize(pango::EllipsizeMode::End) - .max_width_chars(20) - .build(); - hbox.append(&label); - - item_expression - .chain_closure::(closure!( - |_: Option, obj: Option| { - obj.as_ref() - .and_then(|o| profile_from_obj(o)) - .map_or(gettext("None"), |profile| profile.name().to_string()) - } - )) - .bind(&label, "label", glib::Object::NONE); - - if show_selected_indicator { - let selected_indicator = gtk::Image::from_icon_name("object-select-symbolic"); - hbox.append(&selected_indicator); - - gtk::ClosureExpression::new::( - &[ - profile_row.property_expression("selected-item"), - item_expression, - ], - closure!(|_: Option, - selected_item: Option, - item: Option| { - if item == selected_item { - 1.0 - } else { - 0.0 - } - }), - ) - .bind(&selected_indicator, "opacity", glib::Object::NONE); + + let item_row = ItemRow::new(); + item_row.set_warning_tooltip_text(warning_tooltip_text.as_str()); + + list_item.set_child(Some(&item_row)); + })); + + factory.connect_bind(clone!(@weak row => move |_, list_item| { + let list_item = list_item.downcast_ref::().unwrap(); + + let item_row = list_item.child().unwrap().downcast::().unwrap(); + + // Only show the selected icon when it is inside the given row's popover. This assumes that + // the parent of the given row is not a popover, so we can tell which is which. + if item_row.ancestor(gtk::Popover::static_type()).is_some() { + debug_assert!(row.ancestor(gtk::Popover::static_type()).is_none()); + + item_row.set_shows_selected_icon(true); + + unsafe { + list_item.set_data( + ROW_SELECTED_ITEM_NOTIFY_HANDLER_ID_KEY, + row.connect_selected_item_notify(clone!(@weak list_item => move |row| { + update_item_row_is_selected(row, &list_item); + })), + ); + } + + update_item_row_is_selected(&row, list_item); + } else { + item_row.set_shows_selected_icon(false); + } + + bind_cb(list_item); + })); + + factory.connect_unbind(clone!(@weak row => move |_, list_item| { + let list_item = list_item.downcast_ref::().unwrap(); + + unsafe { + if let Some(handler_id) = list_item.steal_data(ROW_SELECTED_ITEM_NOTIFY_HANDLER_ID_KEY) + { + row.disconnect(handler_id); + } } - list_item.set_child(Some(&hbox)); + unbind_cb(list_item); })); + factory } +fn update_item_row_is_selected(row: &adw::ComboRow, list_item: >k::ListItem) { + let item_row = list_item.child().unwrap().downcast::().unwrap(); + + item_row.set_is_selected(row.selected_item() == list_item.item()); +} + +fn update_framerate_row_shows_warning_icon(settings: &Settings, list_item: >k::ListItem) { + let item_row = list_item.child().unwrap().downcast::().unwrap(); + let item = list_item.item().unwrap(); + + let framerate_option = item + .downcast_ref::() + .unwrap() + .borrow::(); + + item_row.set_shows_warning_icon(settings.profile().is_some_and(|profile| { + framerate_option.as_framerate() > profile.suggested_max_framerate() + })); +} + /// Returns `Some` if the object is a `Profile`, otherwise `None`, if the object is a `NoneProfile`. fn profile_from_obj(obj: &glib::Object) -> Option<&Profile> { if let Some(profile) = obj.downcast_ref::() { diff --git a/src/profile.rs b/src/profile.rs index 65193d71..e03f85e9 100644 --- a/src/profile.rs +++ b/src/profile.rs @@ -7,7 +7,9 @@ use gtk::{ use once_cell::sync::OnceCell as OnceLock; use serde::Deserialize; -const DEFAULT_SUGGESTED_MAX_FRAMERATE: u32 = 60; +use crate::pipeline::Framerate; + +const DEFAULT_SUGGESTED_MAX_FRAMERATE: Framerate = Framerate::new_raw(60, 1); const MAX_THREAD_COUNT: u32 = 64; #[derive(Debug, Deserialize)] @@ -23,7 +25,7 @@ struct ProfileData { is_experimental: bool, name: String, #[serde(rename = "suggested-max-fps")] - suggested_max_framerate: Option, + suggested_max_framerate: Option, #[serde(rename = "extension")] file_extension: String, #[serde(rename = "videoenc")] @@ -113,10 +115,11 @@ impl Profile { self.data().audioenc_bin_str.is_some() } - pub fn suggested_max_framerate(&self) -> u32 { - self.data() - .suggested_max_framerate - .unwrap_or(DEFAULT_SUGGESTED_MAX_FRAMERATE) + pub fn suggested_max_framerate(&self) -> Framerate { + self.data().suggested_max_framerate.map_or_else( + || DEFAULT_SUGGESTED_MAX_FRAMERATE, + |raw| Framerate::approximate_float(raw).unwrap(), + ) } pub fn is_experimental(&self) -> bool { @@ -268,7 +271,10 @@ mod tests { assert!(!profile.name().is_empty()); assert!(!profile.file_extension().is_empty()); - assert_ne!(profile.suggested_max_framerate(), 0); + assert_ne!( + profile.suggested_max_framerate(), + Framerate::from_integer(0) + ); assert!( unique.insert(profile.id().to_string()), diff --git a/src/recording.rs b/src/recording.rs index ef8379b1..c5120121 100644 --- a/src/recording.rs +++ b/src/recording.rs @@ -198,7 +198,7 @@ impl Recording { let file_path = new_recording_path(&settings.saving_location(), profile.file_extension()); let mut pipeline_builder = PipelineBuilder::new( &file_path, - settings.video_framerate(), + settings.framerate(), profile.clone(), fd, streams.clone(), diff --git a/src/settings.rs b/src/settings.rs index 56bdfa6d..5a2296be 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -9,6 +9,7 @@ use gtk::{gio, glib}; use crate::{ area_selector::{Selection, SelectionContext}, config::APP_ID, + pipeline::Framerate, profile::Profile, }; @@ -20,6 +21,7 @@ use crate::{ ret_type = "SelectionContext" )] #[gen_settings_skip(key_name = "saving-location")] +#[gen_settings_skip(key_name = "framerate")] #[gen_settings_skip(key_name = "record-delay")] #[gen_settings_skip(key_name = "profile-id")] pub struct Settings; @@ -84,6 +86,25 @@ impl Settings { }) } + pub fn framerate(&self) -> Framerate { + self.0.get::<(i32, i32)>("framerate").into() + } + + pub fn set_framerate(&self, framerate: Framerate) { + let raw: (i32, i32) = framerate.into(); + self.0.set("framerate", raw).unwrap(); + } + + pub fn connect_framerate_changed( + &self, + f: impl Fn(&Self) + 'static, + ) -> gio::glib::SignalHandlerId { + self.0 + .connect_changed(Some("framerate"), move |settings, _| { + f(&Self(settings.clone())); + }) + } + pub fn record_delay(&self) -> Duration { Duration::from_secs(self.0.get::("record-delay") as u64) } diff --git a/src/window/mod.rs b/src/window/mod.rs index a97b5aa9..18b85dee 100644 --- a/src/window/mod.rs +++ b/src/window/mod.rs @@ -17,6 +17,7 @@ use crate::{ cancelled::Cancelled, config::PROFILE, format_time, + framerate_option::FramerateOption, help::Help, recording::{NoProfileError, Recording, RecordingState}, settings::CaptureMode, @@ -517,10 +518,10 @@ impl Window { let profile_text = settings .profile() .map_or_else(|| gettext("None"), |profile| profile.name().to_string()); - let fps_text = settings.video_framerate().to_string(); + let framerate_option = FramerateOption::from_framerate(settings.framerate()); imp.title - .set_subtitle(&format!("{} • {} FPS", profile_text, fps_text)); + .set_subtitle(&format!("{} • {} FPS", profile_text, framerate_option)); } fn update_audio_actions(&self) { @@ -559,7 +560,7 @@ impl Window { obj.update_subtitle_label(); })); - settings.connect_video_framerate_changed(clone!(@weak self as obj => move |_| { + settings.connect_framerate_changed(clone!(@weak self as obj => move |_| { obj.update_subtitle_label(); }));