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 @@
-
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();
}));