├── po ├── LINGUAS ├── meson.build └── POTFILES.in ├── data ├── resources │ ├── screenshots │ │ ├── screenshot1.png │ │ ├── screenshot2.png │ │ └── screenshot3.png │ ├── meson.build │ ├── ui │ │ ├── sync-button.ui │ │ ├── time-label.ui │ │ ├── content-attachment-view-camera-button.ui │ │ ├── content-attachment-view-file-importer-button.ui │ │ ├── content-view-tag-bar-row.ui │ │ ├── tag-editor-row.ui │ │ ├── content-attachment-view-row.ui │ │ ├── note-tag-dialog-row.ui │ │ ├── window.ui │ │ ├── content-attachment-view-picture-row.ui │ │ ├── content-attachment-view-other-row.ui │ │ ├── content-view-tag-bar.ui │ │ ├── shortcuts.ui │ │ ├── sidebar-view-switcher.ui │ │ ├── sidebar-view-switcher-item-row.ui │ │ ├── session.ui │ │ ├── content-attachment-view-audio-recorder-button.ui │ │ ├── content-attachment-view.ui │ │ ├── content-attachment-view-audio-row.ui │ │ ├── content-view.ui │ │ ├── sidebar-note-row.ui │ │ ├── note-tag-dialog.ui │ │ ├── tag-editor.ui │ │ ├── content.ui │ │ ├── camera.ui │ │ ├── picture-viewer.ui │ │ └── sidebar.ui │ ├── icons │ │ └── scalable │ │ │ └── status │ │ │ ├── tag-symbolic.svg │ │ │ ├── editor-symbolic.svg │ │ │ └── external-link-symbolic.svg │ ├── resources.gresource.xml │ └── style.css ├── icons │ ├── meson.build │ ├── io.github.seadve.Noteworthy-symbolic.svg │ └── io.github.seadve.Noteworthy.svg ├── io.github.seadve.Noteworthy.desktop.in.in ├── io.github.seadve.Noteworthy.gschema.xml.in ├── io.github.seadve.Noteworthy.metainfo.xml.in.in └── meson.build ├── .gitignore ├── meson_options.txt ├── src ├── widgets │ ├── mod.rs │ ├── time_label.rs │ └── audio_visualizer.rs ├── config.rs.in ├── core │ ├── note_repository │ │ ├── sync_state.rs │ │ └── repository_watcher.rs │ ├── mod.rs │ ├── audio_recording.rs │ ├── clock_time.rs │ ├── file_type.rs │ ├── point.rs │ ├── date_time.rs │ └── audio_player_handler.rs ├── model │ ├── mod.rs │ ├── note_id.rs │ ├── tag.rs │ ├── note_tag_list.rs │ └── attachment_list.rs ├── session │ ├── sidebar │ │ ├── view_switcher │ │ │ ├── item_kind.rs │ │ │ └── item.rs │ │ └── sync_button.rs │ ├── content │ │ ├── view │ │ │ ├── tag_bar │ │ │ │ ├── row.rs │ │ │ │ └── mod.rs │ │ │ └── mod.rs │ │ ├── attachment_view │ │ │ ├── other_row.rs │ │ │ ├── picture_row.rs │ │ │ ├── camera_button.rs │ │ │ ├── row.rs │ │ │ └── audio_recorder_button.rs │ │ └── mod.rs │ ├── note_tag_dialog │ │ ├── note_tag_lists.rs │ │ └── row.rs │ └── tag_editor │ │ ├── row.rs │ │ └── mod.rs ├── utils.rs ├── main.rs ├── meson.build ├── application.rs └── window.rs ├── Cargo.toml ├── .github └── workflows │ └── ci.yml ├── meson.build └── README.md /po/LINGUAS: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /po/meson.build: -------------------------------------------------------------------------------- 1 | i18n.gettext(gettext_package, preset: 'glib') 2 | -------------------------------------------------------------------------------- /data/resources/screenshots/screenshot1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SeaDve/Noteworthy/HEAD/data/resources/screenshots/screenshot1.png -------------------------------------------------------------------------------- /data/resources/screenshots/screenshot2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SeaDve/Noteworthy/HEAD/data/resources/screenshots/screenshot2.png -------------------------------------------------------------------------------- /data/resources/screenshots/screenshot3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SeaDve/Noteworthy/HEAD/data/resources/screenshots/screenshot3.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | target/ 2 | build/ 3 | _build/ 4 | builddir/ 5 | build-aux/app 6 | build-aux/.flatpak-builder/ 7 | src/config.rs 8 | *.ui.in~ 9 | *.ui~ 10 | .flatpak/ 11 | vendor/ 12 | __pycache__ 13 | .vscode/ 14 | -------------------------------------------------------------------------------- /meson_options.txt: -------------------------------------------------------------------------------- 1 | option( 2 | 'profile', 3 | type: 'combo', 4 | choices: [ 5 | 'default', 6 | 'development' 7 | ], 8 | value: 'default', 9 | description: 'The build profile for Noteworthy. One of "default" or "development".' 10 | ) 11 | -------------------------------------------------------------------------------- /data/resources/meson.build: -------------------------------------------------------------------------------- 1 | # Resources 2 | resources = gnome.compile_resources( 3 | 'resources', 4 | 'resources.gresource.xml', 5 | gresource_bundle: true, 6 | source_dir: meson.current_build_dir(), 7 | install: true, 8 | install_dir: pkgdatadir, 9 | ) 10 | -------------------------------------------------------------------------------- /src/widgets/mod.rs: -------------------------------------------------------------------------------- 1 | mod audio_visualizer; 2 | mod camera; 3 | mod scrollable_picture; 4 | mod time_label; 5 | 6 | pub use self::{ 7 | audio_visualizer::AudioVisualizer, camera::Camera, scrollable_picture::ScrollablePicture, 8 | time_label::TimeLabel, 9 | }; 10 | -------------------------------------------------------------------------------- /data/icons/meson.build: -------------------------------------------------------------------------------- 1 | install_data( 2 | '@0@.svg'.format(application_id), 3 | install_dir: iconsdir / 'hicolor' / 'scalable' / 'apps' 4 | ) 5 | 6 | install_data( 7 | '@0@-symbolic.svg'.format(base_id), 8 | install_dir: iconsdir / 'hicolor' / 'symbolic' / 'apps', 9 | rename: '@0@-symbolic.svg'.format(application_id) 10 | ) 11 | -------------------------------------------------------------------------------- /src/config.rs.in: -------------------------------------------------------------------------------- 1 | pub const APP_ID: &str = @APP_ID@; 2 | pub const GETTEXT_PACKAGE: &str = @GETTEXT_PACKAGE@; 3 | pub const LOCALEDIR: &str = @LOCALEDIR@; 4 | pub const PKGDATADIR: &str = @PKGDATADIR@; 5 | pub const PROFILE: &str = @PROFILE@; 6 | pub const RESOURCES_FILE: &str = concat!(@PKGDATADIR@, "/resources.gresource"); 7 | pub const VERSION: &str = @VERSION@; 8 | -------------------------------------------------------------------------------- /data/io.github.seadve.Noteworthy.desktop.in.in: -------------------------------------------------------------------------------- 1 | [Desktop Entry] 2 | Name=Noteworthy 3 | Comment=Write a GTK + Rust application 4 | Type=Application 5 | Exec=noteworthy 6 | Terminal=false 7 | Categories=GNOME;GTK; 8 | Keywords=Gnome;GTK; 9 | # Translators: Do NOT translate or transliterate this text (this is an icon file name)! 10 | Icon=@icon@ 11 | StartupNotify=true 12 | -------------------------------------------------------------------------------- /src/core/note_repository/sync_state.rs: -------------------------------------------------------------------------------- 1 | use gtk::glib; 2 | 3 | #[derive(Debug, Clone, Copy, PartialEq, glib::Enum)] 4 | #[enum_type(name = "NwtyNoteRepositorySyncState")] 5 | pub enum SyncState { 6 | Syncing, 7 | Pulling, 8 | Pushing, 9 | Idle, 10 | } 11 | 12 | impl Default for SyncState { 13 | fn default() -> Self { 14 | Self::Idle 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /data/resources/ui/sync-button.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 10 | 11 | -------------------------------------------------------------------------------- /src/model/mod.rs: -------------------------------------------------------------------------------- 1 | mod attachment; 2 | mod attachment_list; 3 | mod note; 4 | mod note_id; 5 | mod note_list; 6 | mod note_metadata; 7 | mod note_tag_list; 8 | mod tag; 9 | mod tag_list; 10 | 11 | pub use self::{ 12 | attachment::Attachment, attachment_list::AttachmentList, note::Note, note_id::NoteId, 13 | note_list::NoteList, note_metadata::NoteMetadata, note_tag_list::NoteTagList, tag::Tag, 14 | tag_list::TagList, 15 | }; 16 | -------------------------------------------------------------------------------- /src/session/sidebar/view_switcher/item_kind.rs: -------------------------------------------------------------------------------- 1 | use gtk::glib; 2 | 3 | use super::Tag; 4 | 5 | #[derive(Debug, Clone, glib::Boxed, PartialEq)] 6 | #[boxed_type(name = "NwtySidebarViewSwitcherType")] 7 | pub enum ItemKind { 8 | Separator, 9 | Category, 10 | AllNotes, 11 | EditTags, 12 | Tag(Tag), 13 | Trash, 14 | } 15 | 16 | impl Default for ItemKind { 17 | fn default() -> Self { 18 | Self::AllNotes 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /data/resources/ui/time-label.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 15 | 16 | -------------------------------------------------------------------------------- /data/resources/ui/content-attachment-view-camera-button.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 11 | 12 | -------------------------------------------------------------------------------- /data/resources/ui/content-attachment-view-file-importer-button.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 11 | 12 | -------------------------------------------------------------------------------- /data/resources/ui/content-view-tag-bar-row.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 17 | 18 | -------------------------------------------------------------------------------- /src/core/mod.rs: -------------------------------------------------------------------------------- 1 | mod audio_player; 2 | mod audio_player_handler; 3 | mod audio_recorder; 4 | mod audio_recording; 5 | mod clock_time; 6 | mod date_time; 7 | mod file_type; 8 | mod note_repository; 9 | mod point; 10 | 11 | pub use self::{ 12 | audio_player::{AudioPlayer, PlaybackState}, 13 | audio_player_handler::AudioPlayerHandler, 14 | audio_recorder::AudioRecorder, 15 | audio_recording::AudioRecording, 16 | clock_time::ClockTime, 17 | date_time::DateTime, 18 | file_type::FileType, 19 | note_repository::{NoteRepository, SyncState}, 20 | point::Point, 21 | }; 22 | -------------------------------------------------------------------------------- /data/resources/icons/scalable/status/tag-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /data/resources/icons/scalable/status/editor-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /data/io.github.seadve.Noteworthy.gschema.xml.in: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 1000 6 | Default window width 7 | Default window width 8 | 9 | 10 | 600 11 | Default window height 12 | Default window height 13 | 14 | 15 | false 16 | Default window maximized behaviour 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /po/POTFILES.in: -------------------------------------------------------------------------------- 1 | data/io.github.seadve.Noteworthy.desktop.in.in 2 | data/io.github.seadve.Noteworthy.gschema.xml.in 3 | data/io.github.seadve.Noteworthy.metainfo.xml.in.in 4 | data/resources/ui/content-attachment-view-audio-recorder-button.ui 5 | data/resources/ui/content-attachment-view.ui 6 | data/resources/ui/content.ui 7 | data/resources/ui/note-tag-dialog.ui 8 | data/resources/ui/setup.ui 9 | data/resources/ui/shortcuts.ui 10 | data/resources/ui/sidebar-view-switcher-item-row.ui 11 | data/resources/ui/sidebar.ui 12 | data/resources/ui/tag-editor.ui 13 | src/application.rs 14 | src/main.rs 15 | src/session/content/attachment_view/file_importer_button.rs 16 | src/session/content/view/mod.rs 17 | src/session/note_tag_dialog/mod.rs 18 | src/session/picture_viewer.rs 19 | src/session/sidebar/mod.rs 20 | src/session/sidebar/view_switcher/mod.rs 21 | -------------------------------------------------------------------------------- /data/resources/ui/tag-editor-row.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 25 | 26 | -------------------------------------------------------------------------------- /src/core/audio_recording.rs: -------------------------------------------------------------------------------- 1 | use gtk::{ 2 | gio::{self, prelude::*}, 3 | glib, 4 | }; 5 | 6 | use std::path::{Path, PathBuf}; 7 | 8 | use crate::utils; 9 | 10 | #[derive(Debug)] 11 | pub struct AudioRecording { 12 | file: gio::File, 13 | } 14 | 15 | impl AudioRecording { 16 | pub fn new(base_path: impl AsRef) -> Self { 17 | let path = utils::generate_unique_path(base_path.as_ref(), "AudioRecording", Some("ogg")); 18 | 19 | Self { 20 | file: gio::File::for_path(path), 21 | } 22 | } 23 | 24 | pub fn path(&self) -> PathBuf { 25 | self.file.path().unwrap() 26 | } 27 | 28 | pub async fn delete(self) -> Result<(), glib::Error> { 29 | self.file.delete_future(glib::PRIORITY_DEFAULT_IDLE).await 30 | } 31 | 32 | pub fn into_file(self) -> gio::File { 33 | self.file 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /data/resources/icons/scalable/status/external-link-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /data/resources/ui/content-attachment-view-row.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 27 | 28 | -------------------------------------------------------------------------------- /data/resources/ui/note-tag-dialog-row.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 29 | 30 | -------------------------------------------------------------------------------- /data/resources/ui/window.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 26 | 27 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "noteworthy" 3 | version = "0.1.0" 4 | authors = ["Dave Patrick "] 5 | license = "GPL-3.0-or-later" 6 | edition = "2021" 7 | 8 | [dependencies] 9 | log = "0.4.14" 10 | pretty_env_logger = "0.5.0" 11 | gettext-rs = { version = "0.7.0", features = ["gettext-system"] } 12 | once_cell = "1.10.0" 13 | gtk = { package = "gtk4", version = "0.4.6", features = ["v4_6"] } 14 | gtk_source = { package = "sourceview5", version = "0.4.1" } 15 | gst = { package = "gstreamer", version = "0.18.6" } 16 | gst-plugin-gtk4 = "0.1.1" 17 | gst_pbutils = { package = "gstreamer-pbutils", version = "0.18.0" } 18 | adw = { package = "libadwaita", version = "0.1.0" } 19 | 20 | anyhow = "1.0.56" 21 | indexmap = { version = "2.2", features = ["serde"] } 22 | chrono = { version = "0.4.19", features = ["serde"] } 23 | serde = { version = "1.0.136", features = ["derive"] } 24 | serde_yaml = "0.9" 25 | gray_matter = "0.2.2" 26 | 27 | openssl = "0.10.38" 28 | git2 = "0.18" 29 | regex = "1.5.5" 30 | num_enum = "0.7" 31 | 32 | pulsectl-rs = "0.3.2" 33 | futures-channel = "0.3.21" 34 | thiserror = "1.0.30" 35 | -------------------------------------------------------------------------------- /src/core/clock_time.rs: -------------------------------------------------------------------------------- 1 | use gtk::glib; 2 | 3 | use std::time::Duration; 4 | 5 | /// A boxed [`Duration`](Duration) 6 | #[derive(Debug, Default, Clone, Copy, glib::Boxed)] 7 | #[boxed_type(name = "NwtyClockTime")] 8 | pub struct ClockTime(Duration); 9 | 10 | impl ClockTime { 11 | pub const ZERO: Self = Self(Duration::ZERO); 12 | 13 | pub fn from_secs_f64(secs: f64) -> Self { 14 | Self(Duration::from_secs_f64(secs)) 15 | } 16 | 17 | pub const fn from_secs(secs: u64) -> Self { 18 | Self(Duration::from_secs(secs)) 19 | } 20 | 21 | pub fn as_secs_f64(&self) -> f64 { 22 | self.0.as_secs_f64() 23 | } 24 | 25 | pub const fn as_secs(&self) -> u64 { 26 | self.0.as_secs() 27 | } 28 | } 29 | 30 | impl From for ClockTime { 31 | fn from(value: gst::ClockTime) -> Self { 32 | Self(value.into()) 33 | } 34 | } 35 | 36 | impl TryFrom for gst::ClockTime { 37 | type Error = anyhow::Error; 38 | 39 | fn try_from(value: ClockTime) -> Result { 40 | gst::ClockTime::try_from(value.0).map_err(|err| err.into()) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /data/resources/ui/content-attachment-view-picture-row.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 33 | 34 | -------------------------------------------------------------------------------- /data/resources/ui/content-attachment-view-other-row.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 33 | 34 | -------------------------------------------------------------------------------- /data/resources/ui/content-view-tag-bar.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 33 | 34 | -------------------------------------------------------------------------------- /src/core/file_type.rs: -------------------------------------------------------------------------------- 1 | use gtk::{gio, prelude::*}; 2 | 3 | #[derive(Debug, Clone, Copy, PartialEq)] 4 | pub enum FileType { 5 | Bitmap, 6 | Audio, 7 | Markdown, 8 | Unknown, 9 | } 10 | 11 | impl FileType { 12 | pub fn for_file(file: &gio::File) -> Self { 13 | let res = file.query_info( 14 | &gio::FILE_ATTRIBUTE_STANDARD_CONTENT_TYPE, 15 | gio::FileQueryInfoFlags::NONE, 16 | gio::Cancellable::NONE, 17 | ); 18 | 19 | match res { 20 | Ok(file_info) => { 21 | let mime_type = file_info.content_type().unwrap(); 22 | log::info!("Found mimetype of `{}` for `{}`", mime_type, file.uri()); 23 | 24 | match mime_type.as_str() { 25 | "image/png" | "image/jpeg" => Self::Bitmap, 26 | "audio/x-vorbis+ogg" | "audio/x-opus+ogg" => Self::Audio, 27 | "text/markdown" => Self::Markdown, 28 | _ => Self::Unknown, 29 | } 30 | } 31 | Err(err) => { 32 | log::warn!("Failed to query info for file `{}`: {:?}", file.uri(), err); 33 | Self::Unknown 34 | } 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /data/resources/ui/shortcuts.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | True 5 | 6 | 7 | shortcuts 8 | 10 9 | 10 | 11 | General 12 | 13 | 14 | Show Shortcuts 15 | win.show-help-overlay 16 | 17 | 18 | 19 | 20 | Quit 21 | app.quit 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /src/model/note_id.rs: -------------------------------------------------------------------------------- 1 | use std::{ffi::OsStr, path::Path}; 2 | 3 | // TODO optimize this (Reduce size of id in generating unique file name in utils.rs) 4 | #[derive(Clone, Hash, PartialEq, Eq)] 5 | pub struct NoteId { 6 | id: Box, 7 | } 8 | 9 | impl std::fmt::Debug for NoteId { 10 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 11 | std::fmt::Debug::fmt(&self.id, f) 12 | } 13 | } 14 | 15 | impl NoteId { 16 | pub fn for_path(path: impl AsRef) -> Self { 17 | Self { 18 | id: Box::from(path.as_ref().file_stem().unwrap()), 19 | } 20 | } 21 | } 22 | 23 | #[cfg(test)] 24 | mod test { 25 | use super::*; 26 | use std::collections::HashMap; 27 | 28 | #[test] 29 | fn hash_map() { 30 | let mut hash_map = HashMap::new(); 31 | 32 | let id_0 = NoteId::for_path("Path0"); 33 | hash_map.insert(&id_0, 0); 34 | 35 | let id_1 = NoteId::for_path("Path1"); 36 | hash_map.insert(&id_1, 1); 37 | 38 | let id_2 = NoteId::for_path("Path2"); 39 | hash_map.insert(&id_2, 2); 40 | 41 | assert_eq!(hash_map.get(&id_0), Some(&0)); 42 | assert_eq!(hash_map.get(&id_1), Some(&1)); 43 | assert_eq!(hash_map.get(&NoteId::for_path("Path2")), Some(&2)); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /data/resources/ui/sidebar-view-switcher.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 31 | 32 | -------------------------------------------------------------------------------- /src/core/point.rs: -------------------------------------------------------------------------------- 1 | /// Describes a point with two coordinates. 2 | #[derive(Debug, Clone, Copy, PartialEq)] 3 | pub struct Point { 4 | pub x: f64, 5 | pub y: f64, 6 | } 7 | 8 | impl Point { 9 | /// A point fixed at origin (0, 0). 10 | pub const ZERO: Self = Self::new(0.0, 0.0); 11 | 12 | /// Construct a point with `x` and `y` coordinates. 13 | pub const fn new(x: f64, y: f64) -> Self { 14 | Self { x, y } 15 | } 16 | 17 | /// Construct a point from tuple (x, y). 18 | pub const fn from_tuple(point_tuple: (f64, f64)) -> Self { 19 | Self { 20 | x: point_tuple.0, 21 | y: point_tuple.1, 22 | } 23 | } 24 | } 25 | 26 | #[cfg(test)] 27 | mod test { 28 | use super::*; 29 | 30 | #[test] 31 | fn zero() { 32 | let point = Point::ZERO; 33 | assert_eq!(point.x, 0.0); 34 | assert_eq!(point.y, 0.0); 35 | } 36 | 37 | #[test] 38 | fn new() { 39 | let point = Point::new(1.0, 5.0); 40 | assert_eq!(point.x, 1.0); 41 | assert_eq!(point.y, 5.0); 42 | } 43 | 44 | #[test] 45 | fn tuple() { 46 | let point_tuple = (1.0, 5.0); 47 | let point = Point::from_tuple(point_tuple); 48 | assert_eq!(point.x, 1.0); 49 | assert_eq!(point.y, 5.0); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/core/date_time.rs: -------------------------------------------------------------------------------- 1 | use chrono::Local; 2 | use gtk::glib; 3 | use serde::{Deserialize, Serialize}; 4 | 5 | /// A boxed [`DateTime`](chrono::DateTime) 6 | #[derive( 7 | Debug, Clone, Copy, glib::Boxed, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, 8 | )] 9 | #[boxed_type(name = "NwtyDateTime")] 10 | #[serde(transparent)] 11 | pub struct DateTime(chrono::DateTime); 12 | 13 | impl Default for DateTime { 14 | fn default() -> Self { 15 | Self::now() 16 | } 17 | } 18 | 19 | impl DateTime { 20 | pub fn now() -> Self { 21 | Self(Local::now()) 22 | } 23 | 24 | pub fn fuzzy_display(&self) -> String { 25 | let now = Local::now(); 26 | 27 | let is_today = now.date_naive() == self.0.date_naive(); 28 | let duration = now.signed_duration_since(self.0); 29 | 30 | let hours_difference = duration.num_hours(); 31 | let week_difference = duration.num_weeks(); 32 | 33 | if is_today { 34 | self.0.format("%I∶%M") // 08:10 35 | } else if hours_difference <= 30 { 36 | self.0.format("yesterday") 37 | } else if week_difference <= 52 { 38 | self.0.format("%b %d") // Sep 03 39 | } else { 40 | self.0.format("%b %d %Y") // Sep 03 1920 41 | } 42 | .to_string() 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /data/resources/ui/sidebar-view-switcher-item-row.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 0 5 | 6 | 7 | True 8 | 9 | 10 | 0 11 | 15 | 16 | 17 | session.edit-tags 18 | True 19 | 20 | 21 | 0 22 | Edit Tags 23 | 24 | 25 | 28 | 29 | 39 | 40 | -------------------------------------------------------------------------------- /src/utils.rs: -------------------------------------------------------------------------------- 1 | use gtk::glib; 2 | 3 | use std::{ 4 | ffi::OsStr, 5 | path::{Path, PathBuf}, 6 | }; 7 | 8 | // Taken from fractal-next GPLv3 9 | // See https://gitlab.gnome.org/GNOME/fractal/-/blob/fractal-next/src/utils.rs 10 | /// Spawns a future in the main context 11 | #[macro_export] 12 | macro_rules! spawn { 13 | ($future:expr) => { 14 | let ctx = glib::MainContext::default(); 15 | ctx.spawn_local($future); 16 | }; 17 | ($priority:expr, $future:expr) => { 18 | let ctx = glib::MainContext::default(); 19 | ctx.spawn_local_with_priority($priority, $future); 20 | }; 21 | } 22 | 23 | /// Pushes a function to be executed in the main thread pool 24 | #[macro_export] 25 | macro_rules! spawn_blocking { 26 | ($function:expr) => { 27 | $crate::THREAD_POOL.push_future($function).unwrap() 28 | }; 29 | } 30 | 31 | pub fn default_notes_dir() -> PathBuf { 32 | let mut data_dir = glib::user_data_dir(); 33 | data_dir.push("Notes"); 34 | data_dir 35 | } 36 | 37 | pub fn generate_unique_path( 38 | base_path: impl AsRef, 39 | file_name_prefix: &str, 40 | extension: Option>, 41 | ) -> PathBuf { 42 | let formatted_time = chrono::Local::now().format("%Y-%m-%d-%H-%M-%S-%f"); 43 | let file_name = format!("{}-{}", file_name_prefix, formatted_time); 44 | 45 | let mut path = base_path.as_ref().join(file_name); 46 | 47 | if let Some(extension) = extension { 48 | path.set_extension(extension); 49 | } 50 | 51 | assert!(!path.exists()); 52 | 53 | path 54 | } 55 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: [main] 4 | pull_request: 5 | 6 | name: CI 7 | 8 | jobs: 9 | checks: 10 | name: Checks 11 | runs-on: ubuntu-22.04 12 | steps: 13 | - uses: actions/checkout@v3 14 | # FIXME uncomment when we don't --skip ui_files anymore 15 | # - name: Download dependencies 16 | # run: sudo apt -y install libgtk-4-dev 17 | - name: Run checks.py 18 | run: curl https://raw.githubusercontent.com/SeaDve/scripts/main/checks.py | python - --verbose --skip rustfmt typos ui_files 19 | 20 | rustfmt: 21 | name: Rustfmt 22 | runs-on: ubuntu-22.04 23 | steps: 24 | - uses: actions/checkout@v3 25 | - name: Create blank versions of configured file 26 | run: echo -e "" >> src/config.rs 27 | - name: Run cargo fmt 28 | run: cargo fmt --all -- --check 29 | 30 | typos: 31 | name: Typos 32 | runs-on: ubuntu-22.04 33 | steps: 34 | - uses: actions/checkout@v3 35 | - name: Check for typos 36 | uses: crate-ci/typos@master 37 | 38 | flatpak: 39 | name: Flatpak 40 | runs-on: ubuntu-22.04 41 | container: 42 | image: bilelmoussaoui/flatpak-github-actions:gnome-nightly 43 | options: --privileged 44 | steps: 45 | - uses: actions/checkout@v3 46 | - uses: flatpak/flatpak-github-actions/flatpak-builder@v6 47 | with: 48 | bundle: noteworthy.flatpak 49 | manifest-path: build-aux/io.github.seadve.Noteworthy.Devel.json 50 | run-tests: true 51 | cache-key: flatpak-builder-${{ github.sha }} 52 | -------------------------------------------------------------------------------- /data/resources/ui/session.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 32 | 33 | -------------------------------------------------------------------------------- /data/io.github.seadve.Noteworthy.metainfo.xml.in.in: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | @app-id@ 5 | CC0 6 | 7 | 8 | Noteworthy 9 | Write a GTK + Rust application 10 | 11 |

A boilerplate template for GTK + Rust. It uses Meson as a build system and has flatpak support by default.

12 |
13 | 14 | 15 | https://gitlab.gnome.org/bilelmoussaoui/noteworthy/raw/master/data/resources/screenshots/screenshot1.png 16 | Main window 17 | 18 | 19 | https://gitlab.gnome.org/bilelmoussaoui/noteworthy 20 | https://gitlab.gnome.org/bilelmoussaoui/noteworthy/issues 21 | 22 | 23 | 24 | 25 | 26 | 30 | ModernToolkit 31 | HiDpiIcon 32 | 33 | Dave Patrick 34 | davecruz48@gmail.com 35 | @gettext-package@ 36 | @app-id@.desktop 37 |
38 | -------------------------------------------------------------------------------- /data/resources/ui/content-attachment-view-audio-recorder-button.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 43 | 44 | -------------------------------------------------------------------------------- /data/resources/ui/content-attachment-view.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 50 | 51 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | #![allow(clippy::new_without_default)] 2 | #![warn(clippy::doc_markdown)] 3 | #![warn(clippy::or_fun_call)] 4 | #![warn(clippy::unused_self)] 5 | #![warn(clippy::needless_pass_by_value)] 6 | #![warn(clippy::explicit_iter_loop)] 7 | #![warn(clippy::semicolon_if_nothing_returned)] 8 | #![warn(clippy::match_wildcard_for_single_variants)] 9 | #![warn(clippy::inefficient_to_string)] 10 | #![warn(clippy::await_holding_refcell_ref)] 11 | #![warn(clippy::map_unwrap_or)] 12 | #![warn(clippy::implicit_clone)] 13 | #![warn(clippy::struct_excessive_bools)] 14 | #![warn(clippy::trivially_copy_pass_by_ref)] 15 | #![warn(clippy::unreadable_literal)] 16 | #![warn(clippy::if_not_else)] 17 | #![warn(clippy::doc_markdown)] 18 | 19 | mod application; 20 | mod config; 21 | mod core; 22 | mod model; 23 | mod session; 24 | mod setup; 25 | mod utils; 26 | mod widgets; 27 | mod window; 28 | 29 | use gettextrs::{gettext, LocaleCategory}; 30 | use gtk::{gio, glib}; 31 | use once_cell::sync::Lazy; 32 | 33 | use self::application::Application; 34 | use self::config::{GETTEXT_PACKAGE, LOCALEDIR, RESOURCES_FILE}; 35 | 36 | static THREAD_POOL: Lazy = 37 | Lazy::new(|| glib::ThreadPool::shared(None).expect("Unable to create thread pool")); 38 | 39 | fn main() { 40 | pretty_env_logger::init_timed(); 41 | 42 | gettextrs::setlocale(LocaleCategory::LcAll, ""); 43 | gettextrs::bindtextdomain(GETTEXT_PACKAGE, LOCALEDIR).expect("Unable to bind the text domain"); 44 | gettextrs::textdomain(GETTEXT_PACKAGE).expect("Unable to switch to the text domain"); 45 | 46 | glib::set_application_name(&gettext("Noteworthy")); 47 | 48 | gst::init().expect("Unable to start GStreamer"); 49 | 50 | gstgtk4::plugin_register_static().expect("Failed to register gstgtk4 plugin"); 51 | 52 | let res = gio::Resource::load(RESOURCES_FILE).expect("Could not load gresource file"); 53 | gio::resources_register(&res); 54 | 55 | let app = Application::new(); 56 | app.run(); 57 | } 58 | -------------------------------------------------------------------------------- /data/meson.build: -------------------------------------------------------------------------------- 1 | subdir('icons') 2 | subdir('resources') 3 | # Desktop file 4 | desktop_conf = configuration_data() 5 | desktop_conf.set('icon', application_id) 6 | desktop_file = i18n.merge_file( 7 | type: 'desktop', 8 | input: configure_file( 9 | input: '@0@.desktop.in.in'.format(base_id), 10 | output: '@BASENAME@', 11 | configuration: desktop_conf 12 | ), 13 | output: '@0@.desktop'.format(application_id), 14 | po_dir: podir, 15 | install: true, 16 | install_dir: datadir / 'applications' 17 | ) 18 | # Validate Desktop file 19 | if desktop_file_validate.found() 20 | test( 21 | 'validate-desktop', 22 | desktop_file_validate, 23 | args: [ 24 | desktop_file.full_path() 25 | ], 26 | depends: desktop_file, 27 | ) 28 | endif 29 | 30 | # Appdata 31 | appdata_conf = configuration_data() 32 | appdata_conf.set('app-id', application_id) 33 | appdata_conf.set('gettext-package', gettext_package) 34 | appdata_file = i18n.merge_file( 35 | input: configure_file( 36 | input: '@0@.metainfo.xml.in.in'.format(base_id), 37 | output: '@BASENAME@', 38 | configuration: appdata_conf 39 | ), 40 | output: '@0@.metainfo.xml'.format(application_id), 41 | po_dir: podir, 42 | install: true, 43 | install_dir: datadir / 'metainfo' 44 | ) 45 | # Validate Appdata 46 | if appstream_util.found() 47 | test( 48 | 'validate-appdata', appstream_util, 49 | args: [ 50 | 'validate', '--nonet', appdata_file.full_path() 51 | ], 52 | depends: appdata_file, 53 | ) 54 | endif 55 | 56 | # GSchema 57 | gschema_conf = configuration_data() 58 | gschema_conf.set('app-id', application_id) 59 | gschema_conf.set('gettext-package', gettext_package) 60 | configure_file( 61 | input: '@0@.gschema.xml.in'.format(base_id), 62 | output: '@0@.gschema.xml'.format(application_id), 63 | configuration: gschema_conf, 64 | install: true, 65 | install_dir: datadir / 'glib-2.0' / 'schemas' 66 | ) 67 | 68 | # Validata GSchema 69 | if glib_compile_schemas.found() 70 | test( 71 | 'validate-gschema', glib_compile_schemas, 72 | args: [ 73 | '--strict', '--dry-run', meson.current_build_dir() 74 | ], 75 | ) 76 | endif 77 | -------------------------------------------------------------------------------- /meson.build: -------------------------------------------------------------------------------- 1 | project( 2 | 'noteworthy', 3 | 'rust', 4 | version: '0.1.0', 5 | license: 'GPL-3.0-or-later', 6 | meson_version: '>= 0.59', 7 | ) 8 | 9 | i18n = import('i18n') 10 | gnome = import('gnome') 11 | 12 | base_id = 'io.github.seadve.Noteworthy' 13 | 14 | dependency('glib-2.0', version: '>= 2.66') 15 | dependency('gio-2.0', version: '>= 2.66') 16 | dependency('gtk4', version: '>= 4.5.0') 17 | dependency('libadwaita-1', version: '>= 1.0.0') 18 | dependency('gtksourceview-5', version: '>= 5.0.0') 19 | dependency('gstreamer-1.0', version: '>= 1.18') 20 | dependency('gstreamer-base-1.0', version: '>= 1.18') 21 | dependency('gstreamer-plugins-base-1.0', version: '>= 1.18') 22 | 23 | glib_compile_resources = find_program('glib-compile-resources', required: true) 24 | glib_compile_schemas = find_program('glib-compile-schemas', required: true) 25 | desktop_file_validate = find_program('desktop-file-validate', required: false) 26 | appstream_util = find_program('appstream-util', required: false) 27 | cargo = find_program('cargo', required: true) 28 | 29 | version = meson.project_version() 30 | 31 | prefix = get_option('prefix') 32 | bindir = prefix / get_option('bindir') 33 | localedir = prefix / get_option('localedir') 34 | 35 | datadir = prefix / get_option('datadir') 36 | pkgdatadir = datadir / meson.project_name() 37 | iconsdir = datadir / 'icons' 38 | podir = meson.project_source_root() / 'po' 39 | gettext_package = meson.project_name() 40 | 41 | if get_option('profile') == 'development' 42 | profile = 'Devel' 43 | vcs_tag = run_command('git', 'rev-parse', '--short', 'HEAD').stdout().strip() 44 | if vcs_tag == '' 45 | version_suffix = '-devel' 46 | else 47 | version_suffix = '-@0@'.format(vcs_tag) 48 | endif 49 | application_id = '@0@.@1@'.format(base_id, profile) 50 | else 51 | profile = '' 52 | version_suffix = '' 53 | application_id = base_id 54 | endif 55 | 56 | meson.add_dist_script( 57 | 'build-aux/dist-vendor.sh', 58 | meson.project_build_root() / 'meson-dist' / meson.project_name() + '-' + version, 59 | meson.project_source_root() 60 | ) 61 | 62 | subdir('data') 63 | subdir('po') 64 | subdir('src') 65 | 66 | gnome.post_install( 67 | gtk_update_icon_cache: true, 68 | glib_compile_schemas: true, 69 | update_desktop_database: true, 70 | ) 71 | -------------------------------------------------------------------------------- /data/resources/ui/content-attachment-view-audio-row.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 64 | 65 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | Noteworthy 3 |

4 | 5 |

Modern, Fast, and Version-Controlled Markdown Notes App

6 | 7 |

8 | 9 | CI status 10 | 11 | 12 | Packaging status 13 | 14 |

15 | 16 | ## 0.1.0 Milestone 17 | 18 | - [x] Trash and pinning 19 | - [x] Note creation and deletion 20 | - [x] Note metadata 21 | - [x] Powerful tag system 22 | - [x] Filtering 23 | - [x] Basic markdown 24 | - [x] Batch notes selection and editing 25 | - [x] Attachments 26 | - [ ] Canvas drawing 27 | - [ ] Syncing (Barely working) 28 | - [ ] Git integration (Barely working) 29 | - [ ] Setup page 30 | - [ ] WYSIWG Editing 31 | - [ ] Homepage (Includes reminders, recents, mini notepads etc.) 32 | 33 | 34 | ## Installation Instructions 35 | 36 | Noteworthy is under heavy development. Thus, it is currently not recommended to 37 | be used for day-to-day tasks. However, it is possible to download the nightly 38 | build artifact from the [Actions page](https://github.com/SeaDve/Noteworthy/actions/), 39 | then install it locally by running `flatpak install noteworthy.flatpak`. 40 | 41 | 42 | ## Build Instructions 43 | 44 | ### GNOME Builder 45 | 46 | GNOME Builder is the environment used for developing this application. 47 | It can use Flatpak manifests to create a consistent building and running 48 | environment cross-distro. Thus, it is highly recommended you use it. 49 | 50 | 1. Download [GNOME Builder](https://flathub.org/apps/details/org.gnome.Builder). 51 | 2. In Builder, click the "Clone Repository" button at the bottom, using 52 | `https://github.com/SeaDve/Noteworthy.git` as the URL. 53 | 3. Click the build button at the top once the project is loaded. 54 | 55 | ### Meson 56 | 57 | #### Prerequisites 58 | 59 | The following packages are required to build Noteworthy: 60 | 61 | * meson 62 | * ninja 63 | * appstream-glib (for checks) 64 | * cargo 65 | * gstreamer 66 | * gstreamer-plugins-base 67 | * glib2 68 | * gtk4 69 | * gtksourceview5 70 | * libadwaita 71 | 72 | #### Build Instructions 73 | 74 | ```shell 75 | meson . _build 76 | ninja -C _build 77 | ninja -C _build install 78 | ``` 79 | -------------------------------------------------------------------------------- /src/core/audio_player_handler.rs: -------------------------------------------------------------------------------- 1 | use gtk::{ 2 | glib::{self, clone}, 3 | prelude::*, 4 | subclass::prelude::*, 5 | }; 6 | 7 | use std::{cell::RefCell, collections::HashMap}; 8 | 9 | use super::{AudioPlayer, PlaybackState}; 10 | 11 | mod imp { 12 | use super::*; 13 | 14 | #[derive(Debug, Default)] 15 | pub struct AudioPlayerHandler { 16 | pub list: RefCell>, 17 | } 18 | 19 | #[glib::object_subclass] 20 | impl ObjectSubclass for AudioPlayerHandler { 21 | const NAME: &'static str = "NwtyAudioPlayerHandler"; 22 | type Type = super::AudioPlayerHandler; 23 | } 24 | 25 | impl ObjectImpl for AudioPlayerHandler {} 26 | } 27 | 28 | glib::wrapper! { 29 | pub struct AudioPlayerHandler(ObjectSubclass); 30 | } 31 | 32 | impl AudioPlayerHandler { 33 | pub fn new() -> Self { 34 | glib::Object::new(&[]).expect("Failed to create AudioPlayerHandler.") 35 | } 36 | 37 | pub fn append(&self, audio_player: AudioPlayer) { 38 | let handler_id = 39 | audio_player.connect_state_notify(clone!(@weak self as obj => move |audio_player| { 40 | if audio_player.state() == PlaybackState::Playing { 41 | obj.stop_all_except(audio_player); 42 | log::info!("Stopping all except player with uri `{}`", audio_player.uri()); 43 | } 44 | })); 45 | 46 | self.imp() 47 | .list 48 | .borrow_mut() 49 | .insert(audio_player, handler_id); 50 | } 51 | 52 | pub fn remove(&self, audio_player: &AudioPlayer) { 53 | let handler_id = self 54 | .imp() 55 | .list 56 | .borrow_mut() 57 | .remove(audio_player) 58 | .expect("Trying to remove audio_player that is not handled by this"); 59 | 60 | audio_player.disconnect(handler_id); 61 | } 62 | 63 | pub fn stop_all(&self) { 64 | for audio_player in self.imp().list.borrow().keys() { 65 | audio_player.set_state(PlaybackState::Stopped); 66 | } 67 | } 68 | 69 | fn stop_all_except(&self, exception: &AudioPlayer) { 70 | for audio_player in self.imp().list.borrow().keys() { 71 | if audio_player != exception { 72 | audio_player.set_state(PlaybackState::Stopped); 73 | } 74 | } 75 | } 76 | } 77 | 78 | impl Default for AudioPlayerHandler { 79 | fn default() -> Self { 80 | Self::new() 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/meson.build: -------------------------------------------------------------------------------- 1 | global_conf = configuration_data() 2 | global_conf.set_quoted('APP_ID', application_id) 3 | global_conf.set_quoted('PKGDATADIR', pkgdatadir) 4 | global_conf.set_quoted('PROFILE', profile) 5 | global_conf.set_quoted('VERSION', version + version_suffix) 6 | global_conf.set_quoted('GETTEXT_PACKAGE', gettext_package) 7 | global_conf.set_quoted('LOCALEDIR', localedir) 8 | config = configure_file( 9 | input: 'config.rs.in', 10 | output: 'config.rs', 11 | configuration: global_conf 12 | ) 13 | # Copy the config.rs output to the source directory. 14 | run_command( 15 | 'cp', 16 | meson.project_build_root() / 'src' / 'config.rs', 17 | meson.project_source_root() / 'src' / 'config.rs', 18 | check: true 19 | ) 20 | 21 | manifest_path = meson.project_source_root() / 'Cargo.toml' 22 | cargo_home = meson.project_build_root() / 'cargo-home' 23 | cargo_target_dir = meson.project_build_root() / 'src' 24 | 25 | cargo_options = [ '--manifest-path', manifest_path ] 26 | cargo_options += [ '--target-dir', cargo_target_dir ] 27 | 28 | if get_option('profile') == 'default' 29 | cargo_options += [ '--release' ] 30 | rust_target = 'release' 31 | message('Building in release mode') 32 | else 33 | rust_target = 'debug' 34 | message('Building in debug mode') 35 | endif 36 | 37 | cargo_env = [ 'CARGO_HOME=' + cargo_home ] 38 | 39 | cargo_build = custom_target( 40 | 'cargo-build', 41 | build_by_default: true, 42 | build_always_stale: true, 43 | output: meson.project_name(), 44 | console: true, 45 | install: true, 46 | install_dir: bindir, 47 | depends: resources, 48 | command: [ 49 | 'env', 50 | cargo_env, 51 | cargo, 'build', 52 | cargo_options, 53 | '&&', 54 | 'cp', 'src' / rust_target / meson.project_name(), '@OUTPUT@', 55 | ] 56 | ) 57 | 58 | test( 59 | 'cargo-test', 60 | cargo, 61 | args: [ 62 | 'test', 63 | '--manifest-path=@0@'.format(manifest_path), 64 | '--target-dir=@0@'.format(cargo_target_dir), 65 | '--', 66 | '--nocapture', 67 | ], 68 | env: [ 69 | 'CARGO_HOME=@0@'.format(cargo_home), 70 | 'PATH=/app/bin:/usr/bin:/usr/lib/sdk/rust-stable/bin', 71 | ], 72 | timeout: 300, # give cargo more time 73 | ) 74 | 75 | test( 76 | 'cargo-clippy', 77 | cargo, 78 | args: [ 79 | 'clippy', 80 | '--manifest-path=@0@'.format(manifest_path), 81 | '--target-dir=@0@'.format(cargo_target_dir), 82 | '--', 83 | '-D', 84 | 'warnings', 85 | ], 86 | env: [ 87 | 'CARGO_HOME=@0@'.format(cargo_home), 88 | 'PATH=/app/bin:/usr/bin:/usr/lib/sdk/rust-stable/bin', 89 | ], 90 | timeout: 300, # give cargo more time 91 | ) 92 | -------------------------------------------------------------------------------- /data/resources/ui/content-view.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 66 | 67 | -------------------------------------------------------------------------------- /src/session/content/view/tag_bar/row.rs: -------------------------------------------------------------------------------- 1 | use adw::{prelude::*, subclass::prelude::*}; 2 | use gtk::{glib, subclass::prelude::*}; 3 | 4 | use std::cell::RefCell; 5 | 6 | use crate::model::Tag; 7 | 8 | mod imp { 9 | use super::*; 10 | use gtk::CompositeTemplate; 11 | use once_cell::sync::Lazy; 12 | 13 | #[derive(Debug, Default, CompositeTemplate)] 14 | #[template(resource = "/io/github/seadve/Noteworthy/ui/content-view-tag-bar-row.ui")] 15 | pub struct Row { 16 | #[template_child] 17 | pub label: TemplateChild, 18 | 19 | pub tag: RefCell>, 20 | } 21 | 22 | #[glib::object_subclass] 23 | impl ObjectSubclass for Row { 24 | const NAME: &'static str = "NwtyContentViewTagBarRow"; 25 | type Type = super::Row; 26 | type ParentType = adw::Bin; 27 | 28 | fn class_init(klass: &mut Self::Class) { 29 | Self::bind_template(klass); 30 | } 31 | 32 | fn instance_init(obj: &glib::subclass::InitializingObject) { 33 | obj.init_template(); 34 | } 35 | } 36 | 37 | impl ObjectImpl for Row { 38 | fn properties() -> &'static [glib::ParamSpec] { 39 | static PROPERTIES: Lazy> = Lazy::new(|| { 40 | vec![glib::ParamSpecObject::new( 41 | "tag", 42 | "tag", 43 | "The tag represented by this row", 44 | Tag::static_type(), 45 | glib::ParamFlags::READWRITE, 46 | )] 47 | }); 48 | PROPERTIES.as_ref() 49 | } 50 | 51 | fn set_property( 52 | &self, 53 | _obj: &Self::Type, 54 | _id: usize, 55 | value: &glib::Value, 56 | pspec: &glib::ParamSpec, 57 | ) { 58 | match pspec.name() { 59 | "tag" => { 60 | let tag = value.get().unwrap(); 61 | self.tag.replace(tag); 62 | } 63 | _ => unimplemented!(), 64 | } 65 | } 66 | 67 | fn property(&self, _obj: &Self::Type, _id: usize, pspec: &glib::ParamSpec) -> glib::Value { 68 | match pspec.name() { 69 | "tag" => self.tag.borrow().to_value(), 70 | _ => unimplemented!(), 71 | } 72 | } 73 | } 74 | 75 | impl WidgetImpl for Row {} 76 | impl BinImpl for Row {} 77 | } 78 | 79 | glib::wrapper! { 80 | pub struct Row(ObjectSubclass) 81 | @extends gtk::Widget, adw::Bin, 82 | @implements gtk::Accessible; 83 | } 84 | 85 | impl Row { 86 | pub fn new() -> Self { 87 | glib::Object::new(&[]).expect("Failed to create Row") 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/session/content/view/tag_bar/mod.rs: -------------------------------------------------------------------------------- 1 | mod row; 2 | 3 | use adw::{prelude::*, subclass::prelude::*}; 4 | use gtk::{glib, subclass::prelude::*}; 5 | 6 | use self::row::Row; 7 | use crate::model::NoteTagList; 8 | 9 | mod imp { 10 | use super::*; 11 | use gtk::CompositeTemplate; 12 | use once_cell::sync::Lazy; 13 | 14 | #[derive(Debug, Default, CompositeTemplate)] 15 | #[template(resource = "/io/github/seadve/Noteworthy/ui/content-view-tag-bar.ui")] 16 | pub struct TagBar { 17 | #[template_child] 18 | pub list_view: TemplateChild, 19 | } 20 | 21 | #[glib::object_subclass] 22 | impl ObjectSubclass for TagBar { 23 | const NAME: &'static str = "NwtyContentViewTagBar"; 24 | type Type = super::TagBar; 25 | type ParentType = adw::Bin; 26 | 27 | fn class_init(klass: &mut Self::Class) { 28 | Row::static_type(); 29 | Self::bind_template(klass); 30 | } 31 | 32 | fn instance_init(obj: &glib::subclass::InitializingObject) { 33 | obj.init_template(); 34 | } 35 | } 36 | 37 | impl ObjectImpl for TagBar { 38 | fn properties() -> &'static [glib::ParamSpec] { 39 | static PROPERTIES: Lazy> = Lazy::new(|| { 40 | vec![glib::ParamSpecObject::new( 41 | "tag-list", 42 | "Tag List", 43 | "The model of this view", 44 | NoteTagList::static_type(), 45 | glib::ParamFlags::READWRITE | glib::ParamFlags::EXPLICIT_NOTIFY, 46 | )] 47 | }); 48 | PROPERTIES.as_ref() 49 | } 50 | 51 | fn set_property( 52 | &self, 53 | obj: &Self::Type, 54 | _id: usize, 55 | value: &glib::Value, 56 | pspec: &glib::ParamSpec, 57 | ) { 58 | match pspec.name() { 59 | "tag-list" => { 60 | let tag_list = value.get().unwrap(); 61 | obj.set_tag_list(&tag_list); 62 | } 63 | _ => unimplemented!(), 64 | } 65 | } 66 | } 67 | 68 | impl WidgetImpl for TagBar {} 69 | impl BinImpl for TagBar {} 70 | } 71 | 72 | glib::wrapper! { 73 | pub struct TagBar(ObjectSubclass) 74 | @extends gtk::Widget, adw::Bin, 75 | @implements gtk::Accessible; 76 | } 77 | 78 | impl TagBar { 79 | pub fn new() -> Self { 80 | glib::Object::new(&[]).expect("Failed to create TagBar") 81 | } 82 | 83 | pub fn set_tag_list(&self, tag_list: &NoteTagList) { 84 | let selection_model = gtk::NoSelection::new(Some(tag_list)); 85 | self.imp().list_view.set_model(Some(&selection_model)); 86 | self.notify("tag-list"); 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /data/icons/io.github.seadve.Noteworthy-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 17 | 53 | 58 | 59 | 61 | 66 | 67 | -------------------------------------------------------------------------------- /data/resources/ui/sidebar-note-row.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 79 | 80 | -------------------------------------------------------------------------------- /data/resources/resources.gresource.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | icons/scalable/status/editor-symbolic.svg 5 | icons/scalable/status/external-link-symbolic.svg 6 | icons/scalable/status/tag-symbolic.svg 7 | style.css 8 | ui/camera.ui 9 | ui/content.ui 10 | ui/content-attachment-view.ui 11 | ui/content-attachment-view-audio-recorder-button.ui 12 | ui/content-attachment-view-audio-row.ui 13 | ui/content-attachment-view-camera-button.ui 14 | ui/content-attachment-view-file-importer-button.ui 15 | ui/content-attachment-view-other-row.ui 16 | ui/content-attachment-view-picture-row.ui 17 | ui/content-attachment-view-row.ui 18 | ui/content-view.ui 19 | ui/content-view-tag-bar.ui 20 | ui/content-view-tag-bar-row.ui 21 | ui/note-tag-dialog.ui 22 | ui/note-tag-dialog-row.ui 23 | ui/picture-viewer.ui 24 | ui/session.ui 25 | ui/setup.ui 26 | ui/shortcuts.ui 27 | ui/sidebar.ui 28 | ui/sidebar-note-row.ui 29 | ui/sidebar-view-switcher.ui 30 | ui/sidebar-view-switcher-item-row.ui 31 | ui/sync-button.ui 32 | ui/tag-editor.ui 33 | ui/tag-editor-row.ui 34 | ui/time-label.ui 35 | ui/window.ui 36 | 37 | 38 | -------------------------------------------------------------------------------- /data/resources/ui/note-tag-dialog.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 75 | 76 | -------------------------------------------------------------------------------- /data/resources/ui/tag-editor.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 83 | 84 | -------------------------------------------------------------------------------- /data/resources/ui/content.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 77 | 78 | -------------------------------------------------------------------------------- /src/widgets/time_label.rs: -------------------------------------------------------------------------------- 1 | use gtk::{glib, prelude::*, subclass::prelude::*}; 2 | 3 | use std::cell::Cell; 4 | 5 | use crate::core::ClockTime; 6 | 7 | mod imp { 8 | use super::*; 9 | use gtk::CompositeTemplate; 10 | use once_cell::sync::Lazy; 11 | 12 | #[derive(Debug, Default, CompositeTemplate)] 13 | #[template(resource = "/io/github/seadve/Noteworthy/ui/time-label.ui")] 14 | pub struct TimeLabel { 15 | #[template_child] 16 | pub label: TemplateChild, 17 | 18 | pub time: Cell, 19 | } 20 | 21 | #[glib::object_subclass] 22 | impl ObjectSubclass for TimeLabel { 23 | const NAME: &'static str = "NwtyTimeLabel"; 24 | type Type = super::TimeLabel; 25 | type ParentType = gtk::Widget; 26 | 27 | fn class_init(klass: &mut Self::Class) { 28 | Self::bind_template(klass); 29 | } 30 | 31 | fn instance_init(obj: &glib::subclass::InitializingObject) { 32 | obj.init_template(); 33 | } 34 | } 35 | 36 | impl ObjectImpl for TimeLabel { 37 | fn properties() -> &'static [glib::ParamSpec] { 38 | static PROPERTIES: Lazy> = Lazy::new(|| { 39 | vec![glib::ParamSpecBoxed::new( 40 | "time", 41 | "Time", 42 | "Time being shown by label", 43 | ClockTime::static_type(), 44 | glib::ParamFlags::READWRITE | glib::ParamFlags::EXPLICIT_NOTIFY, 45 | )] 46 | }); 47 | PROPERTIES.as_ref() 48 | } 49 | 50 | fn set_property( 51 | &self, 52 | obj: &Self::Type, 53 | _id: usize, 54 | value: &glib::Value, 55 | pspec: &glib::ParamSpec, 56 | ) { 57 | match pspec.name() { 58 | "time" => { 59 | let time = value.get().unwrap(); 60 | obj.set_time(time); 61 | } 62 | _ => unimplemented!(), 63 | } 64 | } 65 | 66 | fn property(&self, obj: &Self::Type, _id: usize, pspec: &glib::ParamSpec) -> glib::Value { 67 | match pspec.name() { 68 | "time" => obj.time().to_value(), 69 | _ => unimplemented!(), 70 | } 71 | } 72 | 73 | fn constructed(&self, obj: &Self::Type) { 74 | self.parent_constructed(obj); 75 | 76 | obj.reset(); 77 | } 78 | 79 | fn dispose(&self, _obj: &Self::Type) { 80 | self.label.unparent(); 81 | } 82 | } 83 | 84 | impl WidgetImpl for TimeLabel {} 85 | } 86 | 87 | glib::wrapper! { 88 | pub struct TimeLabel(ObjectSubclass) 89 | @extends gtk::Widget; 90 | } 91 | 92 | impl TimeLabel { 93 | pub fn new() -> Self { 94 | glib::Object::new(&[]).expect("Failed to create TimeLabel") 95 | } 96 | 97 | pub fn set_time(&self, time: ClockTime) { 98 | let imp = self.imp(); 99 | 100 | let seconds = time.as_secs(); 101 | let seconds_display = seconds % 60; 102 | let minutes_display = seconds / 60; 103 | let formatted_time = format!("{:02}∶{:02}", minutes_display, seconds_display); 104 | imp.label.set_label(&formatted_time); 105 | 106 | imp.time.set(time); 107 | self.notify("time"); 108 | } 109 | 110 | pub fn time(&self) -> ClockTime { 111 | self.imp().time.get() 112 | } 113 | 114 | pub fn reset(&self) { 115 | self.set_time(ClockTime::ZERO); 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /src/model/tag.rs: -------------------------------------------------------------------------------- 1 | use gtk::{glib, prelude::*, subclass::prelude::*}; 2 | use serde::{Deserialize, Deserializer, Serialize, Serializer}; 3 | 4 | use std::cell::RefCell; 5 | 6 | mod imp { 7 | use super::*; 8 | use once_cell::sync::Lazy; 9 | 10 | #[derive(Debug, Default)] 11 | pub struct Tag { 12 | pub name: RefCell, 13 | } 14 | 15 | #[glib::object_subclass] 16 | impl ObjectSubclass for Tag { 17 | const NAME: &'static str = "NwtyTag"; 18 | type Type = super::Tag; 19 | } 20 | 21 | impl ObjectImpl for Tag { 22 | fn properties() -> &'static [glib::ParamSpec] { 23 | static PROPERTIES: Lazy> = Lazy::new(|| { 24 | vec![glib::ParamSpecString::new( 25 | "name", 26 | "Name", 27 | "Name of the tag", 28 | None, 29 | glib::ParamFlags::READWRITE | glib::ParamFlags::EXPLICIT_NOTIFY, 30 | )] 31 | }); 32 | PROPERTIES.as_ref() 33 | } 34 | 35 | fn set_property( 36 | &self, 37 | obj: &Self::Type, 38 | _id: usize, 39 | value: &glib::Value, 40 | pspec: &glib::ParamSpec, 41 | ) { 42 | match pspec.name() { 43 | "name" => { 44 | let name = value.get().unwrap(); 45 | obj.set_name(name); 46 | } 47 | _ => unimplemented!(), 48 | } 49 | } 50 | 51 | fn property(&self, obj: &Self::Type, _id: usize, pspec: &glib::ParamSpec) -> glib::Value { 52 | match pspec.name() { 53 | "name" => obj.name().to_value(), 54 | _ => unimplemented!(), 55 | } 56 | } 57 | } 58 | } 59 | 60 | glib::wrapper! { 61 | pub struct Tag(ObjectSubclass); 62 | } 63 | 64 | impl Tag { 65 | pub fn new(name: &str) -> Self { 66 | glib::Object::new(&[("name", &name.to_string())]).expect("Failed to create Tag.") 67 | } 68 | 69 | /// Must not be called directly if a tag is in a `TagList` or `NoteTagList`. 70 | /// Use `TagList::rename_tag` instead as it contains sanity checks and other handling. 71 | pub(super) fn set_name(&self, name: &str) { 72 | self.imp().name.replace(name.to_string()); 73 | self.notify("name"); 74 | } 75 | 76 | pub fn name(&self) -> String { 77 | self.imp().name.borrow().clone() 78 | } 79 | 80 | pub fn connect_name_notify(&self, f: F) -> glib::SignalHandlerId 81 | where 82 | F: Fn(&Self) + 'static, 83 | { 84 | self.connect_notify_local(Some("name"), move |obj, _| f(obj)) 85 | } 86 | } 87 | 88 | impl Serialize for Tag { 89 | fn serialize(&self, serializer: S) -> Result { 90 | self.imp().name.serialize(serializer) 91 | } 92 | } 93 | 94 | impl<'de> Deserialize<'de> for Tag { 95 | fn deserialize>(deserializer: D) -> Result { 96 | let name = String::deserialize(deserializer)?; 97 | Ok(Self::new(&name)) 98 | } 99 | } 100 | 101 | #[cfg(test)] 102 | mod test { 103 | use super::*; 104 | 105 | #[test] 106 | fn name() { 107 | let tag = Tag::new("Tag 1"); 108 | assert_eq!(tag.name(), "Tag 1"); 109 | 110 | tag.set_name("New name"); 111 | assert_eq!(tag.name(), "New name"); 112 | } 113 | 114 | #[test] 115 | fn serialize() { 116 | let tag = Tag::new("A tag"); 117 | assert_eq!(serde_yaml::to_string(&tag).unwrap(), "---\nA tag\n"); 118 | } 119 | 120 | #[test] 121 | fn deserialize() { 122 | let tag: Tag = serde_yaml::from_str("A tag").unwrap(); 123 | assert_eq!(tag.name(), "A tag"); 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /src/session/sidebar/sync_button.rs: -------------------------------------------------------------------------------- 1 | use adw::subclass::prelude::*; 2 | use gtk::{glib, prelude::*, subclass::prelude::*}; 3 | 4 | use std::cell::Cell; 5 | 6 | mod imp { 7 | use super::*; 8 | use gtk::CompositeTemplate; 9 | use once_cell::sync::Lazy; 10 | 11 | #[derive(Debug, Default, CompositeTemplate)] 12 | #[template(resource = "/io/github/seadve/Noteworthy/ui/sync-button.ui")] 13 | pub struct SyncButton { 14 | #[template_child] 15 | pub inner_button: TemplateChild, 16 | 17 | pub is_spinning: Cell, 18 | } 19 | 20 | #[glib::object_subclass] 21 | impl ObjectSubclass for SyncButton { 22 | const NAME: &'static str = "NwtySyncButton"; 23 | type Type = super::SyncButton; 24 | type ParentType = adw::Bin; 25 | 26 | fn class_init(klass: &mut Self::Class) { 27 | Self::bind_template(klass); 28 | } 29 | 30 | fn instance_init(obj: &glib::subclass::InitializingObject) { 31 | obj.init_template(); 32 | } 33 | } 34 | 35 | impl ObjectImpl for SyncButton { 36 | fn properties() -> &'static [glib::ParamSpec] { 37 | static PROPERTIES: Lazy> = Lazy::new(|| { 38 | vec![ 39 | glib::ParamSpecString::new( 40 | "action-name", 41 | "Action Name", 42 | "The action to be called when clicked", 43 | None, 44 | glib::ParamFlags::READWRITE | glib::ParamFlags::CONSTRUCT, 45 | ), 46 | glib::ParamSpecBoolean::new( 47 | "is-spinning", 48 | "Is Spinning", 49 | "The action to be called when clicked", 50 | false, 51 | glib::ParamFlags::READWRITE | glib::ParamFlags::EXPLICIT_NOTIFY, 52 | ), 53 | ] 54 | }); 55 | PROPERTIES.as_ref() 56 | } 57 | 58 | fn set_property( 59 | &self, 60 | obj: &Self::Type, 61 | _id: usize, 62 | value: &glib::Value, 63 | pspec: &glib::ParamSpec, 64 | ) { 65 | match pspec.name() { 66 | "action-name" => { 67 | let action_name = value.get().unwrap(); 68 | self.inner_button.set_action_name(action_name); 69 | } 70 | "is-spinning" => { 71 | let is_spinning = value.get().unwrap(); 72 | obj.set_is_spinning(is_spinning); 73 | } 74 | _ => unimplemented!(), 75 | } 76 | } 77 | 78 | fn property(&self, _obj: &Self::Type, _id: usize, pspec: &glib::ParamSpec) -> glib::Value { 79 | match pspec.name() { 80 | "action-name" => self.inner_button.action_name().to_value(), 81 | "is-spinning" => self.is_spinning.get().to_value(), 82 | _ => unimplemented!(), 83 | } 84 | } 85 | } 86 | 87 | impl WidgetImpl for SyncButton {} 88 | impl BinImpl for SyncButton {} 89 | } 90 | 91 | glib::wrapper! { 92 | pub struct SyncButton(ObjectSubclass) 93 | @extends gtk::Widget, adw::Bin, @implements gtk::Accessible; 94 | } 95 | 96 | impl SyncButton { 97 | pub fn new() -> Self { 98 | glib::Object::new(&[]).expect("Failed to create SyncButton.") 99 | } 100 | 101 | pub fn set_is_spinning(&self, is_spinning: bool) { 102 | let imp = self.imp(); 103 | 104 | if is_spinning { 105 | imp.inner_button.add_css_class("spinning"); 106 | } else { 107 | imp.inner_button.remove_css_class("spinning"); 108 | } 109 | 110 | imp.is_spinning.set(is_spinning); 111 | self.notify("is-spinning"); 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /src/session/note_tag_dialog/note_tag_lists.rs: -------------------------------------------------------------------------------- 1 | use gtk::glib; 2 | 3 | use std::{rc::Rc, slice::Iter}; 4 | 5 | use super::{NoteTagList, Tag}; 6 | 7 | #[derive(Debug, Clone, glib::SharedBoxed)] 8 | #[shared_boxed_type(name = "NwtyTagLists")] 9 | pub struct NoteTagLists(Rc>); 10 | 11 | impl From> for NoteTagLists { 12 | fn from(vec: Vec) -> Self { 13 | Self(Rc::new(vec)) 14 | } 15 | } 16 | 17 | impl Default for NoteTagLists { 18 | fn default() -> Self { 19 | Self(Rc::new(Vec::new())) 20 | } 21 | } 22 | 23 | impl NoteTagLists { 24 | pub fn iter(&self) -> Iter { 25 | self.0.iter() 26 | } 27 | 28 | pub fn is_empty(&self) -> bool { 29 | self.0.is_empty() 30 | } 31 | 32 | pub fn first(&self) -> Option<&NoteTagList> { 33 | self.0.first() 34 | } 35 | 36 | /// Append tag on all `NoteTagList` 37 | pub fn append_on_all(&self, tag: &Tag) { 38 | for tag_list in self.iter() { 39 | if tag_list.append(tag.clone()).is_err() { 40 | log::warn!( 41 | "Trying to append an existing tag with name `{}`", 42 | tag.name() 43 | ); 44 | } 45 | } 46 | } 47 | 48 | /// Remove tag on all `NoteTagList` 49 | pub fn remove_on_all(&self, tag: &Tag) { 50 | for tag_list in self.iter() { 51 | if tag_list.remove(tag).is_err() { 52 | log::warn!( 53 | "Trying to remove a tag with name `{}` that doesn't exist in the list", 54 | tag.name() 55 | ); 56 | } 57 | } 58 | } 59 | } 60 | 61 | #[cfg(test)] 62 | mod test { 63 | use super::*; 64 | 65 | #[test] 66 | fn is_empty() { 67 | let note_tag_list_1 = NoteTagList::new(); 68 | note_tag_list_1.append(Tag::new("A")).unwrap(); 69 | let note_tag_list_2 = NoteTagList::new(); 70 | note_tag_list_2.append(Tag::new("A")).unwrap(); 71 | 72 | let note_tag_lists = NoteTagLists::from(vec![note_tag_list_1, note_tag_list_2]); 73 | assert!(!note_tag_lists.is_empty()); 74 | 75 | let note_tag_lists = NoteTagLists::default(); 76 | assert!(note_tag_lists.is_empty()); 77 | } 78 | 79 | #[test] 80 | fn first() { 81 | let note_tag_list_1 = NoteTagList::new(); 82 | note_tag_list_1.append(Tag::new("A")).unwrap(); 83 | let note_tag_list_2 = NoteTagList::new(); 84 | note_tag_list_2.append(Tag::new("A")).unwrap(); 85 | 86 | let note_tag_lists = NoteTagLists::from(vec![note_tag_list_1.clone(), note_tag_list_2]); 87 | assert_eq!(note_tag_lists.first(), Some(¬e_tag_list_1)); 88 | } 89 | 90 | #[test] 91 | fn append_on_all() { 92 | let note_tag_list_1 = NoteTagList::new(); 93 | note_tag_list_1.append(Tag::new("A")).unwrap(); 94 | 95 | let note_tag_list_2 = NoteTagList::new(); 96 | note_tag_list_2.append(Tag::new("A")).unwrap(); 97 | 98 | let note_tag_lists = 99 | NoteTagLists::from(vec![note_tag_list_1.clone(), note_tag_list_2.clone()]); 100 | let tag = Tag::new("B"); 101 | note_tag_lists.append_on_all(&tag); 102 | 103 | assert!(note_tag_list_1.contains(&tag)); 104 | assert!(note_tag_list_2.contains(&tag)); 105 | } 106 | 107 | #[test] 108 | fn remove_on_all() { 109 | let tag = Tag::new("B"); 110 | 111 | let note_tag_list_1 = NoteTagList::new(); 112 | note_tag_list_1.append(Tag::new("A")).unwrap(); 113 | note_tag_list_1.append(tag.clone()).unwrap(); 114 | 115 | let note_tag_list_2 = NoteTagList::new(); 116 | note_tag_list_2.append(Tag::new("A")).unwrap(); 117 | note_tag_list_2.append(tag.clone()).unwrap(); 118 | 119 | let note_tag_lists = 120 | NoteTagLists::from(vec![note_tag_list_1.clone(), note_tag_list_2.clone()]); 121 | note_tag_lists.remove_on_all(&tag); 122 | 123 | assert!(!note_tag_list_1.contains(&tag)); 124 | assert!(!note_tag_list_2.contains(&tag)); 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /src/model/note_tag_list.rs: -------------------------------------------------------------------------------- 1 | use gtk::{ 2 | gio, 3 | glib::{self, clone}, 4 | prelude::*, 5 | subclass::prelude::*, 6 | }; 7 | use indexmap::IndexSet; 8 | use serde::{Deserialize, Deserializer, Serialize, Serializer}; 9 | 10 | use std::cell::RefCell; 11 | 12 | use super::Tag; 13 | use crate::Application; 14 | 15 | mod imp { 16 | use super::*; 17 | 18 | #[derive(Debug, Default)] 19 | pub struct NoteTagList { 20 | pub list: RefCell>, 21 | } 22 | 23 | #[glib::object_subclass] 24 | impl ObjectSubclass for NoteTagList { 25 | const NAME: &'static str = "NwtyNoteTagList"; 26 | type Type = super::NoteTagList; 27 | type Interfaces = (gio::ListModel,); 28 | } 29 | 30 | impl ObjectImpl for NoteTagList {} 31 | 32 | impl ListModelImpl for NoteTagList { 33 | fn item_type(&self, _list_model: &Self::Type) -> glib::Type { 34 | Tag::static_type() 35 | } 36 | 37 | fn n_items(&self, _list_model: &Self::Type) -> u32 { 38 | self.list.borrow().len() as u32 39 | } 40 | 41 | fn item(&self, _list_model: &Self::Type, position: u32) -> Option { 42 | self.list 43 | .borrow() 44 | .get_index(position as usize) 45 | .map(|o| o.upcast_ref::()) 46 | .cloned() 47 | } 48 | } 49 | } 50 | 51 | glib::wrapper! { 52 | pub struct NoteTagList(ObjectSubclass) 53 | @implements gio::ListModel; 54 | } 55 | 56 | impl NoteTagList { 57 | pub fn new() -> Self { 58 | glib::Object::new(&[]).expect("Failed to create NoteTagList.") 59 | } 60 | 61 | pub fn append(&self, tag: Tag) -> anyhow::Result<()> { 62 | tag.connect_name_notify(clone!(@weak self as obj => move |tag| { 63 | if let Some(position) = obj.get_index_of(tag) { 64 | obj.items_changed(position as u32, 1, 1); 65 | } 66 | })); 67 | 68 | let is_list_appended = self.imp().list.borrow_mut().insert(tag); 69 | 70 | anyhow::ensure!(is_list_appended, "Cannot append existing object tag"); 71 | 72 | self.items_changed(self.n_items() - 1, 0, 1); 73 | 74 | Ok(()) 75 | } 76 | 77 | pub fn remove(&self, tag: &Tag) -> anyhow::Result<()> { 78 | let removed = self.imp().list.borrow_mut().shift_remove_full(tag); 79 | 80 | if let Some((position, _)) = removed { 81 | self.items_changed(position as u32, 1, 0); 82 | } else { 83 | anyhow::bail!("Cannot remove tag that does not exist"); 84 | } 85 | 86 | Ok(()) 87 | } 88 | 89 | pub fn contains(&self, tag: &Tag) -> bool { 90 | self.imp().list.borrow().contains(tag) 91 | } 92 | 93 | pub fn is_empty(&self) -> bool { 94 | self.imp().list.borrow().is_empty() 95 | } 96 | 97 | fn get_index_of(&self, tag: &Tag) -> Option { 98 | self.imp().list.borrow().get_index_of(tag) 99 | } 100 | } 101 | 102 | // FIXME better ser & de 103 | impl Serialize for NoteTagList { 104 | fn serialize(&self, serializer: S) -> Result { 105 | self.imp().list.serialize(serializer) 106 | } 107 | } 108 | 109 | impl<'de> Deserialize<'de> for NoteTagList { 110 | fn deserialize>(deserializer: D) -> Result { 111 | let tag_name_list: Vec = Vec::deserialize(deserializer)?; 112 | 113 | let app = Application::default(); 114 | let tag_list = app.main_window().session().note_manager().tag_list(); 115 | 116 | let new_tag_list = Self::new(); 117 | for name in tag_name_list { 118 | let tag = tag_list.get_with_name(&name).unwrap_or_else(|| { 119 | log::error!("Tag with name `{}` not found, Creating new instead", &name); 120 | Tag::new(&name) 121 | }); 122 | new_tag_list.append(tag).unwrap(); 123 | } 124 | 125 | Ok(new_tag_list) 126 | } 127 | } 128 | 129 | impl Default for NoteTagList { 130 | fn default() -> Self { 131 | Self::new() 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /src/session/content/attachment_view/other_row.rs: -------------------------------------------------------------------------------- 1 | use gtk::{ 2 | gio, 3 | glib::{self, clone}, 4 | prelude::*, 5 | subclass::prelude::*, 6 | }; 7 | 8 | use std::cell::RefCell; 9 | 10 | use crate::model::Attachment; 11 | 12 | mod imp { 13 | use super::*; 14 | use gtk::CompositeTemplate; 15 | use once_cell::sync::Lazy; 16 | 17 | #[derive(Debug, Default, CompositeTemplate)] 18 | #[template(resource = "/io/github/seadve/Noteworthy/ui/content-attachment-view-other-row.ui")] 19 | pub struct OtherRow { 20 | pub attachment: RefCell, 21 | } 22 | 23 | #[glib::object_subclass] 24 | impl ObjectSubclass for OtherRow { 25 | const NAME: &'static str = "NwtyContentAttachmentViewOtherRow"; 26 | type Type = super::OtherRow; 27 | type ParentType = gtk::Widget; 28 | 29 | fn class_init(klass: &mut Self::Class) { 30 | Self::bind_template(klass); 31 | 32 | klass.install_action("other-row.launch-file", None, move |obj, _, _| { 33 | obj.on_launch_file(); 34 | }); 35 | } 36 | 37 | fn instance_init(obj: &glib::subclass::InitializingObject) { 38 | obj.init_template(); 39 | } 40 | } 41 | 42 | impl ObjectImpl for OtherRow { 43 | fn properties() -> &'static [glib::ParamSpec] { 44 | static PROPERTIES: Lazy> = Lazy::new(|| { 45 | vec![glib::ParamSpecObject::new( 46 | "attachment", 47 | "attachment", 48 | "The attachment represented by this row", 49 | Attachment::static_type(), 50 | glib::ParamFlags::READWRITE, 51 | )] 52 | }); 53 | PROPERTIES.as_ref() 54 | } 55 | 56 | fn set_property( 57 | &self, 58 | _obj: &Self::Type, 59 | _id: usize, 60 | value: &glib::Value, 61 | pspec: &glib::ParamSpec, 62 | ) { 63 | match pspec.name() { 64 | "attachment" => { 65 | let attachment = value.get().unwrap(); 66 | self.attachment.replace(attachment); 67 | } 68 | _ => unimplemented!(), 69 | } 70 | } 71 | 72 | fn property(&self, _obj: &Self::Type, _id: usize, pspec: &glib::ParamSpec) -> glib::Value { 73 | match pspec.name() { 74 | "attachment" => self.attachment.borrow().to_value(), 75 | _ => unimplemented!(), 76 | } 77 | } 78 | 79 | fn constructed(&self, obj: &Self::Type) { 80 | self.parent_constructed(obj); 81 | 82 | obj.setup_gesture(); 83 | } 84 | 85 | fn dispose(&self, obj: &Self::Type) { 86 | while let Some(child) = obj.first_child() { 87 | child.unparent(); 88 | } 89 | } 90 | } 91 | 92 | impl WidgetImpl for OtherRow {} 93 | } 94 | 95 | glib::wrapper! { 96 | pub struct OtherRow(ObjectSubclass) 97 | @extends gtk::Widget; 98 | } 99 | 100 | impl OtherRow { 101 | pub fn new(attachment: &Attachment) -> Self { 102 | glib::Object::new(&[("attachment", attachment)]).expect("Failed to create OtherRow") 103 | } 104 | 105 | fn attachment(&self) -> Attachment { 106 | self.imp().attachment.borrow().clone() 107 | } 108 | 109 | fn on_launch_file(&self) { 110 | let file_uri = self.attachment().file().uri(); 111 | let res = gio::AppInfo::launch_default_for_uri(&file_uri, gio::AppLaunchContext::NONE); 112 | 113 | if let Err(err) = res { 114 | log::error!("Failed to open file at uri `{}`: {:?}", file_uri, err); 115 | // TODO show user facing error 116 | } 117 | } 118 | 119 | fn setup_gesture(&self) { 120 | let gesture = gtk::GestureClick::new(); 121 | gesture.connect_released(clone!(@weak self as obj => move |_, _, _, _| { 122 | obj.activate_action("other-row.launch-file", None).unwrap(); 123 | })); 124 | self.add_controller(&gesture); 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /src/model/attachment_list.rs: -------------------------------------------------------------------------------- 1 | use gtk::{ 2 | gio, 3 | glib::{self, clone}, 4 | prelude::*, 5 | subclass::prelude::*, 6 | }; 7 | use indexmap::IndexSet; 8 | use serde::{Deserialize, Deserializer, Serialize, Serializer}; 9 | 10 | use std::cell::RefCell; 11 | 12 | use super::Attachment; 13 | 14 | mod imp { 15 | use super::*; 16 | 17 | #[derive(Debug, Default)] 18 | pub struct AttachmentList { 19 | pub list: RefCell>, 20 | } 21 | 22 | #[glib::object_subclass] 23 | impl ObjectSubclass for AttachmentList { 24 | const NAME: &'static str = "NwtyAttachmentList"; 25 | type Type = super::AttachmentList; 26 | type Interfaces = (gio::ListModel,); 27 | } 28 | 29 | impl ObjectImpl for AttachmentList {} 30 | 31 | impl ListModelImpl for AttachmentList { 32 | fn item_type(&self, _list_model: &Self::Type) -> glib::Type { 33 | Attachment::static_type() 34 | } 35 | 36 | fn n_items(&self, _list_model: &Self::Type) -> u32 { 37 | self.list.borrow().len() as u32 38 | } 39 | 40 | fn item(&self, _list_model: &Self::Type, position: u32) -> Option { 41 | self.list 42 | .borrow() 43 | .get_index(position as usize) 44 | .map(|a| a.upcast_ref::()) 45 | .cloned() 46 | } 47 | } 48 | } 49 | 50 | glib::wrapper! { 51 | pub struct AttachmentList(ObjectSubclass) 52 | @implements gio::ListModel; 53 | } 54 | 55 | impl AttachmentList { 56 | pub fn new() -> Self { 57 | glib::Object::new(&[]).expect("Failed to create AttachmentList.") 58 | } 59 | 60 | pub fn append(&self, attachment: Attachment) -> anyhow::Result<()> { 61 | attachment.connect_title_notify(clone!(@weak self as obj => move |attachment| { 62 | if let Some(position) = obj.get_index_of(attachment) { 63 | obj.items_changed(position as u32, 1, 1); 64 | } 65 | })); 66 | 67 | let is_list_appended = self.imp().list.borrow_mut().insert(attachment); 68 | 69 | anyhow::ensure!(is_list_appended, "Cannot append existing object attachment"); 70 | 71 | self.items_changed(self.n_items() - 1, 0, 1); 72 | 73 | Ok(()) 74 | } 75 | 76 | pub fn remove(&self, attachment: &Attachment) -> anyhow::Result<()> { 77 | let removed = self.imp().list.borrow_mut().shift_remove_full(attachment); 78 | 79 | if let Some((position, _)) = removed { 80 | self.items_changed(position as u32, 1, 0); 81 | } else { 82 | anyhow::bail!("Cannot remove attachment that does not exist"); 83 | } 84 | 85 | Ok(()) 86 | } 87 | 88 | pub fn is_empty(&self) -> bool { 89 | self.imp().list.borrow().is_empty() 90 | } 91 | 92 | fn get_index_of(&self, attachment: &Attachment) -> Option { 93 | self.imp().list.borrow().get_index_of(attachment) 94 | } 95 | } 96 | 97 | impl std::iter::FromIterator for AttachmentList { 98 | fn from_iter>(iter: I) -> Self { 99 | let attachment_list = Self::new(); 100 | 101 | for attachment in iter { 102 | if let Err(err) = attachment_list.append(attachment) { 103 | log::warn!("Error appending an attachment, skipping: {:?}", err); 104 | } 105 | } 106 | 107 | attachment_list 108 | } 109 | } 110 | 111 | impl Serialize for AttachmentList { 112 | fn serialize(&self, serializer: S) -> Result { 113 | self.imp().list.serialize(serializer) 114 | } 115 | } 116 | 117 | impl<'de> Deserialize<'de> for AttachmentList { 118 | fn deserialize>(deserializer: D) -> Result { 119 | let attachments: Vec = Vec::deserialize(deserializer)?; 120 | 121 | let attachment_list = attachments.into_iter().collect::(); 122 | 123 | Ok(attachment_list) 124 | } 125 | } 126 | 127 | impl Default for AttachmentList { 128 | fn default() -> Self { 129 | Self::new() 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /data/resources/ui/camera.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 92 | 93 | -------------------------------------------------------------------------------- /data/resources/style.css: -------------------------------------------------------------------------------- 1 | audiovisualizer { 2 | color: @accent_color; 3 | } 4 | 5 | .spinning > image { 6 | animation-name: spin; 7 | animation-duration: 1s; 8 | animation-iteration-count: infinite; 9 | animation-timing-function: linear; 10 | } 11 | 12 | @keyframes spin { 13 | to { 14 | transform: rotate(360deg); 15 | } 16 | } 17 | 18 | 19 | /* Content */ 20 | .content-view { 21 | padding-left: 24px; 22 | padding-right: 24px; 23 | padding-top: 18px; 24 | padding-bottom: 18px; 25 | background-color: @view_bg_color; 26 | } 27 | 28 | .content-view-tag-bar row { 29 | padding-top: 0; 30 | padding-bottom: 0; 31 | padding-left: 3px; 32 | padding-right: 3px; 33 | } 34 | 35 | .content-view-tag-bar row label { 36 | padding-left: 6px; 37 | padding-right: 6px; 38 | border-radius: 9px; 39 | background-color: @accent_bg_color; 40 | color: @accent_fg_color; 41 | } 42 | 43 | .content-attachment-view { 44 | min-width: 240px; 45 | } 46 | 47 | .content-attachment-view-list-view { 48 | padding-top: 6px; 49 | padding-bottom: 6px; 50 | } 51 | 52 | .content-attachment-view-list-view row { 53 | padding: 12px; 54 | padding-top: 6px; 55 | padding-bottom: 6px; 56 | } 57 | 58 | .content-attachment-view-audio-row { 59 | padding: 6px; 60 | padding-left: 12px; 61 | padding-right: 0; 62 | } 63 | 64 | .content-attachment-view-other-row { 65 | padding: 6px; 66 | padding-left: 12px; 67 | padding-right: 0; 68 | } 69 | 70 | .content-attachment-view-picture-row { 71 | padding: 6px; 72 | padding-left: 12px; 73 | } 74 | 75 | .content-attachment-view-picture-row > frame { 76 | border-radius: 6px; 77 | } 78 | 79 | 80 | /* NoteTagDialog */ 81 | .note-tag-dialog-list-view row { 82 | padding: 12px; 83 | } 84 | 85 | .note-tag-dialog-create-tag-button { 86 | padding: 12px; 87 | border-radius: 0; 88 | color: @view_fg_color; 89 | } 90 | 91 | .note-tag-dialog-create-tag-button:not(:hover) { 92 | box-shadow: none; 93 | background-color: @view_bg_color; 94 | } 95 | 96 | .note-tag-dialog-create-tag-button label { 97 | font-weight: normal; 98 | } 99 | 100 | 101 | /* Sidebar */ 102 | .sidebar { 103 | min-width: 300px; 104 | } 105 | 106 | .sidebar-list-view row { 107 | min-height: 30px; 108 | padding: 9px; 109 | } 110 | 111 | .sidebar-list-view-multi-selection-mode row:selected:not(:hover) { 112 | background: none; 113 | } 114 | 115 | .sidebar-view-switcher-popover contents { 116 | padding: 0; 117 | } 118 | 119 | .sidebar-view-switcher-list-view { 120 | padding-top: 6px; 121 | padding-bottom: 6px; 122 | } 123 | 124 | .sidebar-view-switcher-list-view row { 125 | padding-left: 6px; 126 | padding-right: 6px; 127 | min-width: 120px; 128 | } 129 | 130 | .sidebar-view-switcher-list-view row > itemrow > image { 131 | padding-right: 9px; 132 | } 133 | 134 | .sidebar-view-switcher-list-view row > itemrow > label { 135 | padding-left: 9px; 136 | padding-right: 9px; 137 | padding-top: 6px; 138 | padding-bottom: 6px; 139 | } 140 | 141 | .sidebar-view-switcher-list-view row:selected { 142 | background: none; 143 | } 144 | 145 | .sidebar-view-switcher-list-view row:hover { 146 | background: none; 147 | } 148 | 149 | .sidebar-view-switcher-item-row-edit-tags:not(:hover) { 150 | background: none; 151 | box-shadow: none; 152 | } 153 | 154 | .sidebar-view-switcher-item-row-edit-tags label { 155 | font-weight: normal; 156 | } 157 | 158 | 159 | /* TagEditor */ 160 | .tag-editor-create-tag { 161 | padding: 12px; 162 | padding-bottom: 6px; 163 | background-color: @view_bg_color; 164 | } 165 | 166 | .tag-editor-list-view row { 167 | padding-top: 6px; 168 | padding-bottom: 6px; 169 | padding-left: 12px; 170 | padding-right: 6px; 171 | } 172 | 173 | .tag-editor-list-view row:hover { 174 | background: none; 175 | } 176 | 177 | 178 | /* Camera */ 179 | .camera-control-box { 180 | padding: 12px; 181 | } 182 | 183 | .camera-capture-button { 184 | background-color: @accent_fg_color; 185 | border-radius: 100%; 186 | margin: 3px; 187 | opacity: 0.8; 188 | } 189 | 190 | .camera-capture-button:hover { 191 | opacity: 1; 192 | } 193 | 194 | .camera-capture-button:active { 195 | opacity: 0.5; 196 | } 197 | 198 | .camera-capture-button-ring { 199 | opacity: 0.8; 200 | border: 0.3em solid @accent_fg_color; 201 | border-radius: 100%; 202 | } 203 | 204 | .camera-capture-button-ring:active { 205 | opacity: 1; 206 | } 207 | 208 | 209 | /* Setup */ 210 | .setup-button-box { 211 | min-width: 240px; 212 | } 213 | -------------------------------------------------------------------------------- /src/session/tag_editor/row.rs: -------------------------------------------------------------------------------- 1 | use gtk::{ 2 | glib::{self, clone}, 3 | prelude::*, 4 | subclass::prelude::*, 5 | }; 6 | 7 | use std::cell::RefCell; 8 | 9 | use super::TagEditor; 10 | use crate::model::Tag; 11 | 12 | mod imp { 13 | use super::*; 14 | use gtk::CompositeTemplate; 15 | use once_cell::sync::Lazy; 16 | 17 | #[derive(Debug, Default, CompositeTemplate)] 18 | #[template(resource = "/io/github/seadve/Noteworthy/ui/tag-editor-row.ui")] 19 | pub struct Row { 20 | #[template_child] 21 | pub entry: TemplateChild, 22 | 23 | pub binding: RefCell>, 24 | 25 | pub tag: RefCell>, 26 | } 27 | 28 | #[glib::object_subclass] 29 | impl ObjectSubclass for Row { 30 | const NAME: &'static str = "NwtyTagEditorRow"; 31 | type Type = super::Row; 32 | type ParentType = gtk::Widget; 33 | 34 | fn class_init(klass: &mut Self::Class) { 35 | Self::bind_template(klass); 36 | 37 | klass.install_action("tag-editor-row.delete-tag", None, move |obj, _, _| { 38 | let tag_editor = obj.root().unwrap().downcast::().unwrap(); 39 | let tag_list = tag_editor.tag_list(); 40 | let note_list = tag_editor.note_list(); 41 | 42 | // TODO add confirmation dialog before deleting tag 43 | 44 | let tag = obj.tag().unwrap(); 45 | 46 | tag_list.remove(&tag).unwrap(); 47 | note_list.remove_tag_on_all(&tag); 48 | }); 49 | } 50 | 51 | fn instance_init(obj: &glib::subclass::InitializingObject) { 52 | obj.init_template(); 53 | } 54 | } 55 | 56 | impl ObjectImpl for Row { 57 | fn properties() -> &'static [glib::ParamSpec] { 58 | static PROPERTIES: Lazy> = Lazy::new(|| { 59 | vec![glib::ParamSpecObject::new( 60 | "tag", 61 | "tag", 62 | "The tag represented by this row", 63 | Tag::static_type(), 64 | glib::ParamFlags::READWRITE | glib::ParamFlags::EXPLICIT_NOTIFY, 65 | )] 66 | }); 67 | PROPERTIES.as_ref() 68 | } 69 | 70 | fn set_property( 71 | &self, 72 | obj: &Self::Type, 73 | _id: usize, 74 | value: &glib::Value, 75 | pspec: &glib::ParamSpec, 76 | ) { 77 | match pspec.name() { 78 | "tag" => { 79 | let tag = value.get().unwrap(); 80 | obj.set_tag(tag); 81 | } 82 | _ => unimplemented!(), 83 | } 84 | } 85 | 86 | fn property(&self, obj: &Self::Type, _id: usize, pspec: &glib::ParamSpec) -> glib::Value { 87 | match pspec.name() { 88 | "tag" => obj.tag().to_value(), 89 | _ => unimplemented!(), 90 | } 91 | } 92 | 93 | fn dispose(&self, obj: &Self::Type) { 94 | while let Some(child) = obj.first_child() { 95 | child.unparent(); 96 | } 97 | } 98 | } 99 | 100 | impl WidgetImpl for Row {} 101 | } 102 | 103 | glib::wrapper! { 104 | pub struct Row(ObjectSubclass) 105 | @extends gtk::Widget; 106 | } 107 | 108 | impl Row { 109 | pub fn new() -> Self { 110 | glib::Object::new(&[]).expect("Failed to create Row") 111 | } 112 | 113 | fn set_tag(&self, tag: Option) { 114 | let imp = self.imp(); 115 | 116 | if let Some(binding) = imp.binding.take() { 117 | binding.unbind(); 118 | } 119 | 120 | if let Some(ref tag) = tag { 121 | imp.entry.set_text(&tag.name()); 122 | imp.entry 123 | .connect_text_notify(clone!(@weak tag, @weak self as obj => move |entry| { 124 | let tag_list = obj.root().unwrap().downcast::().unwrap().tag_list(); 125 | let tag = obj.tag().unwrap(); 126 | let new_name = entry.text(); 127 | 128 | if new_name != tag.name() && tag_list.rename_tag(&tag, &new_name).is_err() { 129 | entry.add_css_class("error"); 130 | } else { 131 | entry.remove_css_class("error"); 132 | } 133 | })); 134 | } 135 | 136 | imp.tag.replace(tag); 137 | self.notify("tag"); 138 | } 139 | 140 | fn tag(&self) -> Option { 141 | self.imp().tag.borrow().clone() 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /data/icons/io.github.seadve.Noteworthy.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | -------------------------------------------------------------------------------- /src/widgets/audio_visualizer.rs: -------------------------------------------------------------------------------- 1 | // Based on code from GNOME Sound Recorder GPLv3 2 | // Modified to be bidirectional and use snapshots instead of cairo 3 | // See https://gitlab.gnome.org/GNOME/gnome-sound-recorder/-/blob/master/src/waveform.js 4 | 5 | use gtk::{gdk, glib, graphene, gsk, prelude::*, subclass::prelude::*}; 6 | 7 | use std::{cell::RefCell, collections::VecDeque}; 8 | 9 | const GUTTER: f32 = 6.0; 10 | const LINE_WIDTH: f32 = 3.0; 11 | const LINE_RADIUS: f32 = 8.0; 12 | 13 | mod imp { 14 | use super::*; 15 | 16 | #[derive(Debug, Default)] 17 | pub struct AudioVisualizer { 18 | pub peaks: RefCell>, 19 | } 20 | 21 | #[glib::object_subclass] 22 | impl ObjectSubclass for AudioVisualizer { 23 | const NAME: &'static str = "NwtyAudioVisualizer"; 24 | type Type = super::AudioVisualizer; 25 | type ParentType = gtk::Widget; 26 | 27 | fn class_init(klass: &mut Self::Class) { 28 | klass.set_css_name("audiovisualizer"); 29 | } 30 | } 31 | 32 | impl ObjectImpl for AudioVisualizer {} 33 | 34 | impl WidgetImpl for AudioVisualizer { 35 | fn snapshot(&self, obj: &Self::Type, snapshot: >k::Snapshot) { 36 | obj.on_snapshot(snapshot); 37 | } 38 | } 39 | } 40 | 41 | glib::wrapper! { 42 | pub struct AudioVisualizer(ObjectSubclass) 43 | @extends gtk::Widget; 44 | } 45 | 46 | impl AudioVisualizer { 47 | pub fn new() -> Self { 48 | glib::Object::new(&[]).expect("Failed to create AudioVisualizer") 49 | } 50 | 51 | pub fn push_peak(&self, peak: f32) { 52 | let mut peaks = self.peaks_mut(); 53 | 54 | if peaks.len() as i32 > self.allocated_width() / (2 * GUTTER as i32) { 55 | peaks.pop_front(); 56 | } 57 | 58 | peaks.push_back(peak); 59 | 60 | self.queue_draw(); 61 | } 62 | 63 | pub fn clear_peaks(&self) { 64 | self.peaks_mut().clear(); 65 | 66 | self.queue_draw(); 67 | } 68 | 69 | fn peaks(&self) -> std::cell::Ref> { 70 | self.imp().peaks.borrow() 71 | } 72 | 73 | fn peaks_mut(&self) -> std::cell::RefMut> { 74 | self.imp().peaks.borrow_mut() 75 | } 76 | 77 | fn on_snapshot(&self, snapshot: >k::Snapshot) { 78 | let width = self.width() as f32; 79 | let height = self.height() as f32; 80 | 81 | let h_center = width / 2.0; 82 | let v_center = height / 2.0; 83 | 84 | let mut pointer_a = h_center; 85 | let mut pointer_b = h_center; 86 | 87 | let clear = gdk::RGBA::new(0.0, 0.0, 0.0, 0.0); 88 | let color = self.style_context().color(); 89 | 90 | let peaks = self.peaks(); 91 | let peaks_len = peaks.len(); 92 | 93 | for (index, peak) in self.peaks().iter().enumerate().rev() { 94 | let peak_max_height = 95 | ease_in_out_quad(index as f32 / peaks_len as f32) * (*peak) * v_center; 96 | 97 | let top_point = v_center + peak_max_height; 98 | let this_height = -2.0 * peak_max_height; 99 | 100 | let rect_a = graphene::Rect::new(pointer_a, top_point, LINE_WIDTH, this_height); 101 | let rect_b = graphene::Rect::new(pointer_b, top_point, LINE_WIDTH, this_height); 102 | 103 | pointer_a -= GUTTER; 104 | pointer_b += GUTTER; 105 | 106 | snapshot.push_rounded_clip(&gsk::RoundedRect::from_rect(rect_a, LINE_RADIUS)); 107 | snapshot.append_linear_gradient( 108 | &graphene::Rect::new(0.0, 0.0, h_center, height), 109 | &graphene::Point::new(0.0, v_center), 110 | &graphene::Point::new(h_center, v_center), 111 | &[ 112 | gsk::ColorStop::new(0.0, clear), 113 | gsk::ColorStop::new(1.0, color), 114 | ], 115 | ); 116 | snapshot.pop(); 117 | 118 | snapshot.push_rounded_clip(&gsk::RoundedRect::from_rect(rect_b, LINE_RADIUS)); 119 | snapshot.append_linear_gradient( 120 | &graphene::Rect::new(h_center, 0.0, h_center, height), 121 | &graphene::Point::new(width, v_center), 122 | &graphene::Point::new(h_center, v_center), 123 | &[ 124 | gsk::ColorStop::new(0.0, clear), 125 | gsk::ColorStop::new(1.0, color), 126 | ], 127 | ); 128 | snapshot.pop(); 129 | } 130 | } 131 | } 132 | 133 | fn ease_in_out_quad(x: f32) -> f32 { 134 | if x < 0.5 { 135 | 2.0 * x * x 136 | } else { 137 | (-2.0 * x * x) + x.mul_add(4.0, -1.0) 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /src/session/content/attachment_view/picture_row.rs: -------------------------------------------------------------------------------- 1 | use gtk::{ 2 | gdk, gio, 3 | glib::{self, clone}, 4 | prelude::*, 5 | subclass::prelude::*, 6 | }; 7 | 8 | use std::cell::RefCell; 9 | 10 | use crate::{model::Attachment, session::Session, spawn, spawn_blocking}; 11 | 12 | mod imp { 13 | use super::*; 14 | use gtk::CompositeTemplate; 15 | use once_cell::sync::Lazy; 16 | 17 | #[derive(Debug, Default, CompositeTemplate)] 18 | #[template(resource = "/io/github/seadve/Noteworthy/ui/content-attachment-view-picture-row.ui")] 19 | pub struct PictureRow { 20 | #[template_child] 21 | pub picture: TemplateChild, 22 | 23 | pub attachment: RefCell, 24 | } 25 | 26 | #[glib::object_subclass] 27 | impl ObjectSubclass for PictureRow { 28 | const NAME: &'static str = "NwtyContentAttachmentViewPictureRow"; 29 | type Type = super::PictureRow; 30 | type ParentType = gtk::Widget; 31 | 32 | fn class_init(klass: &mut Self::Class) { 33 | Self::bind_template(klass); 34 | } 35 | 36 | fn instance_init(obj: &glib::subclass::InitializingObject) { 37 | obj.init_template(); 38 | } 39 | } 40 | 41 | impl ObjectImpl for PictureRow { 42 | fn properties() -> &'static [glib::ParamSpec] { 43 | static PROPERTIES: Lazy> = Lazy::new(|| { 44 | vec![glib::ParamSpecObject::new( 45 | "attachment", 46 | "attachment", 47 | "The attachment represented by this row", 48 | Attachment::static_type(), 49 | glib::ParamFlags::READWRITE | glib::ParamFlags::EXPLICIT_NOTIFY, 50 | )] 51 | }); 52 | PROPERTIES.as_ref() 53 | } 54 | 55 | fn set_property( 56 | &self, 57 | obj: &Self::Type, 58 | _id: usize, 59 | value: &glib::Value, 60 | pspec: &glib::ParamSpec, 61 | ) { 62 | match pspec.name() { 63 | "attachment" => { 64 | let attachment = value.get().unwrap(); 65 | obj.set_attachment(attachment); 66 | } 67 | _ => unimplemented!(), 68 | } 69 | } 70 | 71 | fn property(&self, obj: &Self::Type, _id: usize, pspec: &glib::ParamSpec) -> glib::Value { 72 | match pspec.name() { 73 | "attachment" => obj.attachment().to_value(), 74 | _ => unimplemented!(), 75 | } 76 | } 77 | 78 | fn constructed(&self, obj: &Self::Type) { 79 | self.parent_constructed(obj); 80 | 81 | obj.setup_gesture(); 82 | } 83 | 84 | fn dispose(&self, obj: &Self::Type) { 85 | while let Some(child) = obj.first_child() { 86 | child.unparent(); 87 | } 88 | } 89 | } 90 | 91 | impl WidgetImpl for PictureRow {} 92 | } 93 | 94 | glib::wrapper! { 95 | pub struct PictureRow(ObjectSubclass) 96 | @extends gtk::Widget; 97 | } 98 | 99 | impl PictureRow { 100 | pub fn new(attachment: &Attachment) -> Self { 101 | glib::Object::new(&[("attachment", attachment)]).expect("Failed to create PictureRow") 102 | } 103 | 104 | fn set_attachment(&self, attachment: Attachment) { 105 | if attachment == self.attachment() { 106 | return; 107 | } 108 | 109 | let file = attachment.file(); 110 | let path = file.path().unwrap(); 111 | 112 | spawn!(clone!(@weak self as obj => async move { 113 | match obj.load_texture_from_file(file).await { 114 | Ok(ref texture) => { 115 | obj.imp().picture.set_paintable(Some(texture)); 116 | } 117 | Err(err) => { 118 | log::error!( 119 | "Failed to load texture from file `{}`: {:?}", 120 | path.display(), 121 | err 122 | ); 123 | } 124 | } 125 | })); 126 | 127 | self.imp().attachment.replace(attachment); 128 | self.notify("attachment"); 129 | } 130 | 131 | fn attachment(&self) -> Attachment { 132 | self.imp().attachment.borrow().clone() 133 | } 134 | 135 | async fn load_texture_from_file(&self, file: gio::File) -> Result { 136 | spawn_blocking!(move || gdk::Texture::from_file(&file)).await 137 | } 138 | 139 | fn setup_gesture(&self) { 140 | let gesture = gtk::GestureClick::new(); 141 | gesture.connect_released(clone!(@weak self as obj => move |_, _, _, _| { 142 | Session::default().show_attachment(obj.attachment()); 143 | })); 144 | self.add_controller(&gesture); 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /src/application.rs: -------------------------------------------------------------------------------- 1 | use adw::subclass::prelude::*; 2 | use gettextrs::gettext; 3 | use gtk::{ 4 | gio, 5 | glib::{self, clone}, 6 | prelude::*, 7 | subclass::prelude::*, 8 | }; 9 | 10 | use crate::{ 11 | config::{APP_ID, PKGDATADIR, PROFILE, VERSION}, 12 | window::Window, 13 | }; 14 | 15 | mod imp { 16 | use super::*; 17 | use glib::WeakRef; 18 | use once_cell::unsync::OnceCell; 19 | 20 | #[derive(Debug)] 21 | pub struct Application { 22 | pub window: OnceCell>, 23 | pub settings: gio::Settings, 24 | } 25 | 26 | #[glib::object_subclass] 27 | impl ObjectSubclass for Application { 28 | const NAME: &'static str = "NwtyApplication"; 29 | type Type = super::Application; 30 | type ParentType = adw::Application; 31 | 32 | fn new() -> Self { 33 | Self { 34 | window: OnceCell::new(), 35 | settings: gio::Settings::new(APP_ID), 36 | } 37 | } 38 | } 39 | 40 | impl ObjectImpl for Application {} 41 | 42 | impl ApplicationImpl for Application { 43 | fn activate(&self, obj: &Self::Type) { 44 | self.parent_activate(obj); 45 | 46 | if let Some(window) = self.window.get() { 47 | let window = window.upgrade().unwrap(); 48 | window.present(); 49 | return; 50 | } 51 | 52 | let window = Window::new(obj); 53 | self.window 54 | .set(window.downgrade()) 55 | .expect("Window already set."); 56 | 57 | obj.main_window().present(); 58 | } 59 | 60 | fn startup(&self, obj: &Self::Type) { 61 | self.parent_startup(obj); 62 | 63 | gtk::Window::set_default_icon_name(APP_ID); 64 | 65 | obj.setup_gactions(); 66 | obj.setup_accels(); 67 | } 68 | } 69 | 70 | impl GtkApplicationImpl for Application {} 71 | impl AdwApplicationImpl for Application {} 72 | } 73 | 74 | glib::wrapper! { 75 | pub struct Application(ObjectSubclass) 76 | @extends gio::Application, gtk::Application, adw::Application, 77 | @implements gio::ActionMap, gio::ActionGroup; 78 | } 79 | 80 | impl Application { 81 | pub fn new() -> Self { 82 | glib::Object::new(&[ 83 | ("application-id", &Some(APP_ID)), 84 | ("flags", &gio::ApplicationFlags::empty()), 85 | ("resource-base-path", &Some("/io/github/seadve/Noteworthy/")), 86 | ]) 87 | .expect("Application initialization failed...") 88 | } 89 | 90 | pub fn run(&self) { 91 | log::info!("Noteworthy ({})", APP_ID); 92 | log::info!("Version: {} ({})", VERSION, PROFILE); 93 | log::info!("Datadir: {}", PKGDATADIR); 94 | 95 | ApplicationExtManual::run(self); 96 | } 97 | 98 | pub fn settings(&self) -> gio::Settings { 99 | self.imp().settings.clone() 100 | } 101 | 102 | pub fn main_window(&self) -> Window { 103 | self.imp().window.get().unwrap().upgrade().unwrap() 104 | } 105 | 106 | fn show_about_dialog(&self) { 107 | let dialog = gtk::AboutDialog::builder() 108 | .transient_for(&self.main_window()) 109 | .modal(true) 110 | // .comments(&gettext("Elegantly record your screen")) 111 | .version(VERSION) 112 | .logo_icon_name(APP_ID) 113 | .authors(vec!["Dave Patrick".into()]) 114 | // Translators: Replace "translator-credits" with your names. Put a comma between. 115 | .translator_credits(&gettext("translator-credits")) 116 | .copyright(&gettext("Copyright 2022 Dave Patrick")) 117 | .license_type(gtk::License::Gpl30) 118 | .website("https://github.com/SeaDve/Noteworthy") 119 | .website_label(&gettext("GitHub")) 120 | .build(); 121 | 122 | dialog.present(); 123 | } 124 | 125 | fn setup_gactions(&self) { 126 | let action_quit = gio::SimpleAction::new("quit", None); 127 | action_quit.connect_activate(clone!(@weak self as obj => move |_, _| { 128 | // This is needed to trigger the delete event and saving the window state 129 | obj.main_window().close(); 130 | obj.quit(); 131 | })); 132 | self.add_action(&action_quit); 133 | 134 | let action_about = gio::SimpleAction::new("about", None); 135 | action_about.connect_activate(clone!(@weak self as obj => move |_, _| { 136 | obj.show_about_dialog(); 137 | })); 138 | self.add_action(&action_about); 139 | } 140 | 141 | fn setup_accels(&self) { 142 | self.set_accels_for_action("app.quit", &["q"]); 143 | } 144 | } 145 | 146 | impl Default for Application { 147 | fn default() -> Self { 148 | gio::Application::default().unwrap().downcast().unwrap() 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /src/session/sidebar/view_switcher/item.rs: -------------------------------------------------------------------------------- 1 | use gtk::{gio, glib, prelude::*, subclass::prelude::*}; 2 | 3 | use std::cell::RefCell; 4 | 5 | use super::ItemKind; 6 | 7 | mod imp { 8 | use super::*; 9 | use once_cell::sync::Lazy; 10 | 11 | #[derive(Debug, Default)] 12 | pub struct Item { 13 | kind: RefCell, 14 | display_name: RefCell>, 15 | model: RefCell>, 16 | } 17 | 18 | #[glib::object_subclass] 19 | impl ObjectSubclass for Item { 20 | const NAME: &'static str = "NwtySidebarViewSwitcherItem"; 21 | type Type = super::Item; 22 | } 23 | 24 | impl ObjectImpl for Item { 25 | fn properties() -> &'static [glib::ParamSpec] { 26 | static PROPERTIES: Lazy> = Lazy::new(|| { 27 | vec![ 28 | glib::ParamSpecBoxed::new( 29 | "kind", 30 | "Kind", 31 | "Kind of this item", 32 | ItemKind::static_type(), 33 | glib::ParamFlags::READWRITE | glib::ParamFlags::CONSTRUCT_ONLY, 34 | ), 35 | glib::ParamSpecString::new( 36 | "display-name", 37 | "Display Name", 38 | "Display name of this item", 39 | None, 40 | glib::ParamFlags::READWRITE | glib::ParamFlags::CONSTRUCT, 41 | ), 42 | glib::ParamSpecObject::new( 43 | "model", 44 | "Model", 45 | "The model of this item", 46 | gio::ListModel::static_type(), 47 | glib::ParamFlags::READWRITE | glib::ParamFlags::CONSTRUCT_ONLY, 48 | ), 49 | ] 50 | }); 51 | PROPERTIES.as_ref() 52 | } 53 | 54 | fn set_property( 55 | &self, 56 | _obj: &Self::Type, 57 | _id: usize, 58 | value: &glib::Value, 59 | pspec: &glib::ParamSpec, 60 | ) { 61 | match pspec.name() { 62 | "kind" => { 63 | let kind = value.get().unwrap(); 64 | self.kind.replace(kind); 65 | } 66 | "display-name" => { 67 | let display_name = value.get().unwrap(); 68 | self.display_name.replace(display_name); 69 | } 70 | "model" => { 71 | let model = value.get().unwrap(); 72 | self.model.replace(model); 73 | } 74 | _ => unimplemented!(), 75 | } 76 | } 77 | 78 | fn property(&self, _obj: &Self::Type, _id: usize, pspec: &glib::ParamSpec) -> glib::Value { 79 | match pspec.name() { 80 | "kind" => self.kind.borrow().to_value(), 81 | "display-name" => self.display_name.borrow().to_value(), 82 | "model" => self.model.borrow().to_value(), 83 | _ => unimplemented!(), 84 | } 85 | } 86 | } 87 | } 88 | 89 | glib::wrapper! { 90 | pub struct Item(ObjectSubclass); 91 | } 92 | 93 | impl Item { 94 | pub const fn builder(kind: ItemKind) -> ItemBuilder { 95 | ItemBuilder::new(kind) 96 | } 97 | 98 | pub fn kind(&self) -> ItemKind { 99 | self.property("kind") 100 | } 101 | 102 | pub fn display_name(&self) -> Option { 103 | self.property("display-name") 104 | } 105 | 106 | pub fn model(&self) -> Option { 107 | self.property("model") 108 | } 109 | } 110 | 111 | pub struct ItemBuilder { 112 | kind: ItemKind, 113 | display_name: Option, 114 | model: Option, 115 | } 116 | 117 | impl ItemBuilder { 118 | pub const fn new(kind: ItemKind) -> Self { 119 | Self { 120 | kind, 121 | display_name: None, 122 | model: None, 123 | } 124 | } 125 | 126 | pub fn display_name(mut self, display_name: &str) -> Self { 127 | self.display_name = Some(display_name.to_string()); 128 | self 129 | } 130 | 131 | pub fn model(mut self, model: &impl IsA) -> Self { 132 | self.model = Some(model.clone().upcast()); 133 | self 134 | } 135 | 136 | pub fn build(self) -> Item { 137 | let mut properties: Vec<(&str, &dyn ToValue)> = vec![("kind", &self.kind)]; 138 | 139 | if let Some(ref display_name) = self.display_name { 140 | properties.push(("display-name", display_name)); 141 | } 142 | 143 | if let Some(ref model) = self.model { 144 | properties.push(("model", model)); 145 | } 146 | 147 | glib::Object::new(&properties).expect("Failed to create an instance of Item") 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /data/resources/ui/picture-viewer.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 114 | 115 | -------------------------------------------------------------------------------- /data/resources/ui/sidebar.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |
5 | 6 | _Preferences 7 | app.preferences 8 | 9 | 10 | _Keyboard Shortcuts 11 | win.show-help-overlay 12 | 13 | 14 | _About Noteworthy 15 | app.about 16 | 17 |
18 |
19 | 20 |
21 | 22 | Select _All 23 | sidebar.select-all 24 | 25 | 26 | Select _None 27 | sidebar.select-none 28 | 29 |
30 |
31 | 128 |
129 | -------------------------------------------------------------------------------- /src/session/content/view/mod.rs: -------------------------------------------------------------------------------- 1 | mod tag_bar; 2 | 3 | use adw::subclass::prelude::*; 4 | use gettextrs::gettext; 5 | use gtk::{ 6 | glib::{self, closure}, 7 | prelude::*, 8 | subclass::prelude::*, 9 | }; 10 | use gtk_source::prelude::*; 11 | 12 | use std::cell::RefCell; 13 | 14 | use self::tag_bar::TagBar; 15 | use crate::{ 16 | core::DateTime, 17 | model::{Note, NoteMetadata}, 18 | }; 19 | 20 | mod imp { 21 | use super::*; 22 | use gtk::CompositeTemplate; 23 | use once_cell::sync::Lazy; 24 | 25 | #[derive(Debug, Default, CompositeTemplate)] 26 | #[template(resource = "/io/github/seadve/Noteworthy/ui/content-view.ui")] 27 | pub struct View { 28 | #[template_child] 29 | pub title_label: TemplateChild, 30 | #[template_child] 31 | pub last_modified_label: TemplateChild, 32 | #[template_child] 33 | pub tag_bar: TemplateChild, 34 | #[template_child] 35 | pub source_view: TemplateChild, 36 | 37 | pub bindings: RefCell>, 38 | 39 | pub note: RefCell>, 40 | } 41 | 42 | #[glib::object_subclass] 43 | impl ObjectSubclass for View { 44 | const NAME: &'static str = "NwtyContentView"; 45 | type Type = super::View; 46 | type ParentType = adw::Bin; 47 | 48 | fn class_init(klass: &mut Self::Class) { 49 | Self::bind_template(klass); 50 | } 51 | 52 | fn instance_init(obj: &glib::subclass::InitializingObject) { 53 | obj.init_template(); 54 | } 55 | } 56 | 57 | impl ObjectImpl for View { 58 | fn properties() -> &'static [glib::ParamSpec] { 59 | static PROPERTIES: Lazy> = Lazy::new(|| { 60 | vec![glib::ParamSpecObject::new( 61 | "note", 62 | "Note", 63 | "Current note in the view", 64 | Note::static_type(), 65 | glib::ParamFlags::READWRITE | glib::ParamFlags::EXPLICIT_NOTIFY, 66 | )] 67 | }); 68 | PROPERTIES.as_ref() 69 | } 70 | 71 | fn set_property( 72 | &self, 73 | obj: &Self::Type, 74 | _id: usize, 75 | value: &glib::Value, 76 | pspec: &glib::ParamSpec, 77 | ) { 78 | match pspec.name() { 79 | "note" => { 80 | let note = value.get().unwrap(); 81 | obj.set_note(note); 82 | } 83 | _ => unimplemented!(), 84 | } 85 | } 86 | 87 | fn property(&self, obj: &Self::Type, _id: usize, pspec: &glib::ParamSpec) -> glib::Value { 88 | match pspec.name() { 89 | "note" => obj.note().to_value(), 90 | _ => unimplemented!(), 91 | } 92 | } 93 | 94 | fn constructed(&self, obj: &Self::Type) { 95 | self.parent_constructed(obj); 96 | 97 | // For some reason Buffer:style-scheme default is set to something making it 98 | // not follow libadwaita's StyleManager:is-dark 99 | let title_label_buffer = self 100 | .title_label 101 | .buffer() 102 | .downcast::() 103 | .unwrap(); 104 | title_label_buffer.set_style_scheme(None); 105 | 106 | obj.setup_expressions(); 107 | } 108 | } 109 | 110 | impl WidgetImpl for View {} 111 | impl BinImpl for View {} 112 | } 113 | 114 | glib::wrapper! { 115 | pub struct View(ObjectSubclass) 116 | @extends gtk::Widget, adw::Bin; 117 | } 118 | 119 | impl View { 120 | pub fn new() -> Self { 121 | glib::Object::new(&[]).expect("Failed to create View.") 122 | } 123 | 124 | pub fn note(&self) -> Option { 125 | self.imp().note.borrow().clone() 126 | } 127 | 128 | pub fn set_note(&self, note: Option) { 129 | let imp = self.imp(); 130 | 131 | for binding in imp.bindings.borrow_mut().drain(..) { 132 | binding.unbind(); 133 | } 134 | 135 | if let Some(ref note) = note { 136 | imp.source_view.grab_focus(); 137 | 138 | let mut bindings = imp.bindings.borrow_mut(); 139 | 140 | let title_binding = note 141 | .metadata() 142 | .bind_property("title", &imp.title_label.get().buffer(), "text") 143 | .flags(glib::BindingFlags::SYNC_CREATE | glib::BindingFlags::BIDIRECTIONAL) 144 | .build(); 145 | bindings.push(title_binding); 146 | } 147 | 148 | imp.source_view 149 | .set_buffer(note.as_ref().map(|note| note.buffer())); 150 | 151 | imp.note.replace(note); 152 | self.notify("note"); 153 | } 154 | 155 | fn setup_expressions(&self) { 156 | Self::this_expression("note") 157 | .chain_property::("metadata") 158 | .chain_property::("last-modified") 159 | .chain_closure::(closure!(|_: Self, last_modified: DateTime| { 160 | gettext!("Last edited {}", last_modified.fuzzy_display()) 161 | })) 162 | .bind(&self.imp().last_modified_label.get(), "label", Some(self)); 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /src/session/content/attachment_view/camera_button.rs: -------------------------------------------------------------------------------- 1 | use adw::{prelude::*, subclass::prelude::*}; 2 | use gtk::{ 3 | gio, 4 | glib::{self, clone}, 5 | subclass::prelude::*, 6 | }; 7 | 8 | use crate::{session::Session, utils, widgets::Camera, Application}; 9 | 10 | mod imp { 11 | use super::*; 12 | use glib::subclass::Signal; 13 | use gtk::CompositeTemplate; 14 | use once_cell::sync::Lazy; 15 | 16 | #[derive(Debug, Default, CompositeTemplate)] 17 | #[template( 18 | resource = "/io/github/seadve/Noteworthy/ui/content-attachment-view-camera-button.ui" 19 | )] 20 | pub struct CameraButton { 21 | pub camera: Camera, 22 | } 23 | 24 | #[glib::object_subclass] 25 | impl ObjectSubclass for CameraButton { 26 | const NAME: &'static str = "NwtyContentAttachmentViewCameraButton"; 27 | type Type = super::CameraButton; 28 | type ParentType = adw::Bin; 29 | 30 | fn class_init(klass: &mut Self::Class) { 31 | Self::bind_template(klass); 32 | 33 | klass.install_action("camera-button.launch", None, move |obj, _, _| { 34 | obj.on_launch(); 35 | }); 36 | } 37 | 38 | fn instance_init(obj: &glib::subclass::InitializingObject) { 39 | obj.init_template(); 40 | } 41 | } 42 | 43 | impl ObjectImpl for CameraButton { 44 | fn signals() -> &'static [Signal] { 45 | static SIGNALS: Lazy> = Lazy::new(|| { 46 | vec![ 47 | Signal::builder( 48 | "capture-done", 49 | &[gio::File::static_type().into()], 50 | <()>::static_type().into(), 51 | ) 52 | .build(), 53 | Signal::builder("on-launch", &[], <()>::static_type().into()).build(), 54 | ] 55 | }); 56 | SIGNALS.as_ref() 57 | } 58 | 59 | fn constructed(&self, obj: &Self::Type) { 60 | self.parent_constructed(obj); 61 | 62 | obj.setup_signals(); 63 | } 64 | } 65 | 66 | impl WidgetImpl for CameraButton {} 67 | impl BinImpl for CameraButton {} 68 | } 69 | 70 | glib::wrapper! { 71 | pub struct CameraButton(ObjectSubclass) 72 | @extends gtk::Widget, adw::Bin, 73 | @implements gtk::Accessible; 74 | } 75 | 76 | impl CameraButton { 77 | pub fn new() -> Self { 78 | glib::Object::new(&[]).expect("Failed to create CameraButton") 79 | } 80 | 81 | pub fn connect_on_launch(&self, f: F) -> glib::SignalHandlerId 82 | where 83 | F: Fn(&Self) + 'static, 84 | { 85 | self.connect_local("on-launch", true, move |values| { 86 | let obj = values[0].get::().unwrap(); 87 | f(&obj); 88 | None 89 | }) 90 | } 91 | 92 | pub fn connect_capture_done(&self, f: F) -> glib::SignalHandlerId 93 | where 94 | F: Fn(&Self, &gio::File) + 'static, 95 | { 96 | self.connect_local("capture-done", true, move |values| { 97 | let obj = values[0].get::().unwrap(); 98 | let file = values[1].get::().unwrap(); 99 | f(&obj, &file); 100 | None 101 | }) 102 | } 103 | 104 | fn setup_signals(&self) { 105 | let imp = self.imp(); 106 | 107 | imp.camera 108 | .connect_capture_accept(clone!(@weak self as obj => move |_, texture| { 109 | let notes_dir = Session::default().directory(); 110 | let file_path = utils::generate_unique_path(notes_dir, "Camera", Some("png")); 111 | 112 | if let Err(err) = texture.save_to_png(&file_path) { 113 | log::error!("Failed to save texture to png: {:?}", err); 114 | } 115 | 116 | obj.emit_by_name::<()>("capture-done", &[&gio::File::for_path(&file_path)]); 117 | })); 118 | 119 | imp.camera 120 | .connect_on_exit(clone!(@weak self as obj => move |camera| { 121 | let main_window = Application::default().main_window(); 122 | main_window.switch_to_session_page(); 123 | 124 | // TODO Remove the page on exit. 125 | // The blocker is when you add, remove, then add again the same widget, 126 | // there will be critical errors and the actions will be disabled. 127 | // See https://gitlab.gnome.org/GNOME/gtk/-/issues/4421 128 | 129 | if let Err(err) = camera.stop() { 130 | log::warn!("Failed to stop camera: {:?}", err); 131 | } else { 132 | log::info!("Successfully stopped camera"); 133 | } 134 | })); 135 | } 136 | 137 | fn on_launch(&self) { 138 | self.emit_by_name::<()>("on-launch", &[]); 139 | 140 | let imp = self.imp(); 141 | 142 | let main_window = Application::default().main_window(); 143 | 144 | if !main_window.has_page(&imp.camera) { 145 | main_window.add_page(&imp.camera); 146 | } 147 | 148 | main_window.set_visible_page(&imp.camera); 149 | 150 | if let Err(err) = imp.camera.start() { 151 | log::error!("Failed to start camera: {:?}", err); 152 | } else { 153 | log::info!("Successfully started camera"); 154 | } 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /src/session/content/attachment_view/row.rs: -------------------------------------------------------------------------------- 1 | use adw::prelude::*; 2 | use gtk::{glib, subclass::prelude::*}; 3 | 4 | use std::cell::RefCell; 5 | 6 | use super::{AudioRow, OtherRow, PictureRow}; 7 | use crate::{core::FileType, model::Attachment}; 8 | 9 | mod imp { 10 | use super::*; 11 | use glib::subclass::Signal; 12 | use gtk::CompositeTemplate; 13 | use once_cell::sync::Lazy; 14 | 15 | #[derive(Debug, Default, CompositeTemplate)] 16 | #[template(resource = "/io/github/seadve/Noteworthy/ui/content-attachment-view-row.ui")] 17 | pub struct Row { 18 | #[template_child] 19 | pub content: TemplateChild, 20 | 21 | pub attachment: RefCell>, 22 | } 23 | 24 | #[glib::object_subclass] 25 | impl ObjectSubclass for Row { 26 | const NAME: &'static str = "NwtyContentAttachmentViewRow"; 27 | type Type = super::Row; 28 | type ParentType = gtk::Widget; 29 | 30 | fn class_init(klass: &mut Self::Class) { 31 | Self::bind_template(klass); 32 | 33 | klass.install_action("row.delete-attachment", None, move |obj, _, _| { 34 | obj.emit_by_name::<()>("on-delete", &[]); 35 | }); 36 | } 37 | 38 | fn instance_init(obj: &glib::subclass::InitializingObject) { 39 | obj.init_template(); 40 | } 41 | } 42 | 43 | impl ObjectImpl for Row { 44 | fn signals() -> &'static [Signal] { 45 | static SIGNALS: Lazy> = Lazy::new(|| { 46 | vec![Signal::builder("on-delete", &[], <()>::static_type().into()).build()] 47 | }); 48 | SIGNALS.as_ref() 49 | } 50 | 51 | fn properties() -> &'static [glib::ParamSpec] { 52 | static PROPERTIES: Lazy> = Lazy::new(|| { 53 | vec![glib::ParamSpecObject::new( 54 | "attachment", 55 | "attachment", 56 | "The attachment represented by this row", 57 | Attachment::static_type(), 58 | glib::ParamFlags::READWRITE | glib::ParamFlags::EXPLICIT_NOTIFY, 59 | )] 60 | }); 61 | PROPERTIES.as_ref() 62 | } 63 | 64 | fn set_property( 65 | &self, 66 | obj: &Self::Type, 67 | _id: usize, 68 | value: &glib::Value, 69 | pspec: &glib::ParamSpec, 70 | ) { 71 | match pspec.name() { 72 | "attachment" => { 73 | let attachment = value.get().unwrap(); 74 | obj.set_attachment(attachment); 75 | } 76 | _ => unimplemented!(), 77 | } 78 | } 79 | 80 | fn property(&self, obj: &Self::Type, _id: usize, pspec: &glib::ParamSpec) -> glib::Value { 81 | match pspec.name() { 82 | "attachment" => obj.attachment().to_value(), 83 | _ => unimplemented!(), 84 | } 85 | } 86 | 87 | fn dispose(&self, obj: &Self::Type) { 88 | while let Some(child) = obj.first_child() { 89 | child.unparent(); 90 | } 91 | } 92 | } 93 | 94 | impl WidgetImpl for Row {} 95 | } 96 | 97 | glib::wrapper! { 98 | pub struct Row(ObjectSubclass) 99 | @extends gtk::Widget; 100 | } 101 | 102 | impl Row { 103 | pub fn new() -> Self { 104 | glib::Object::new(&[]).expect("Failed to create Row") 105 | } 106 | 107 | pub fn attachment(&self) -> Option { 108 | self.imp().attachment.borrow().clone() 109 | } 110 | 111 | pub fn set_attachment(&self, attachment: Option) { 112 | if attachment == self.attachment() { 113 | return; 114 | } 115 | 116 | if let Some(ref attachment) = attachment { 117 | self.replace_child(attachment); 118 | } else { 119 | self.remove_child(); 120 | } 121 | 122 | self.imp().attachment.replace(attachment); 123 | self.notify("attachment"); 124 | } 125 | 126 | pub fn inner_row>(&self) -> Option { 127 | self.imp() 128 | .content 129 | .child() 130 | .and_then(|w| w.downcast::().ok()) 131 | } 132 | 133 | pub fn connect_on_delete(&self, f: F) -> glib::SignalHandlerId 134 | where 135 | F: Fn(&Self) + 'static, 136 | { 137 | self.connect_local("on-delete", true, move |values| { 138 | let obj = values[0].get::().unwrap(); 139 | f(&obj); 140 | None 141 | }) 142 | } 143 | 144 | fn replace_child(&self, attachment: &Attachment) { 145 | // TODO make other row activatable too 146 | let child: gtk::Widget = match attachment.file_type() { 147 | FileType::Audio => { 148 | self.remove_css_class("activatable"); 149 | AudioRow::new(attachment).upcast() 150 | } 151 | FileType::Bitmap => { 152 | self.add_css_class("activatable"); 153 | PictureRow::new(attachment).upcast() 154 | } 155 | FileType::Markdown | FileType::Unknown => { 156 | self.add_css_class("activatable"); 157 | OtherRow::new(attachment).upcast() 158 | } 159 | }; 160 | 161 | self.imp().content.set_child(Some(&child)); 162 | } 163 | 164 | fn remove_child(&self) { 165 | self.imp().content.set_child(gtk::Widget::NONE); 166 | } 167 | } 168 | -------------------------------------------------------------------------------- /src/core/note_repository/repository_watcher.rs: -------------------------------------------------------------------------------- 1 | use gtk::{ 2 | gio, 3 | glib::{self, clone}, 4 | prelude::*, 5 | subclass::prelude::*, 6 | }; 7 | use once_cell::unsync::OnceCell; 8 | 9 | use std::{thread, time::Duration}; 10 | 11 | use super::Repository; 12 | 13 | const DEFAULT_SLEEP_TIME_SECS: u64 = 3; 14 | 15 | mod imp { 16 | use super::*; 17 | use glib::subclass::Signal; 18 | use once_cell::sync::Lazy; 19 | 20 | #[derive(Default, Debug)] 21 | pub struct RepositoryWatcher { 22 | pub base_path: OnceCell, 23 | pub remote_name: OnceCell, 24 | } 25 | 26 | #[glib::object_subclass] 27 | impl ObjectSubclass for RepositoryWatcher { 28 | const NAME: &'static str = "NwtyRepositoryWatcher"; 29 | type Type = super::RepositoryWatcher; 30 | } 31 | 32 | impl ObjectImpl for RepositoryWatcher { 33 | fn signals() -> &'static [Signal] { 34 | static SIGNALS: Lazy> = Lazy::new(|| { 35 | vec![Signal::builder("remote-changed", &[], <()>::static_type().into()).build()] 36 | }); 37 | SIGNALS.as_ref() 38 | } 39 | 40 | fn properties() -> &'static [glib::ParamSpec] { 41 | static PROPERTIES: Lazy> = Lazy::new(|| { 42 | vec![ 43 | glib::ParamSpecObject::new( 44 | "base-path", 45 | "Base Path", 46 | "Where the repository is stored locally", 47 | gio::File::static_type(), 48 | glib::ParamFlags::READWRITE | glib::ParamFlags::CONSTRUCT_ONLY, 49 | ), 50 | glib::ParamSpecString::new( 51 | "remote-name", 52 | "Remote Name", 53 | "Remote name where the repo will be stored (e.g. origin)", 54 | None, 55 | glib::ParamFlags::READWRITE | glib::ParamFlags::CONSTRUCT_ONLY, 56 | ), 57 | ] 58 | }); 59 | PROPERTIES.as_ref() 60 | } 61 | 62 | fn set_property( 63 | &self, 64 | _obj: &Self::Type, 65 | _id: usize, 66 | value: &glib::Value, 67 | pspec: &glib::ParamSpec, 68 | ) { 69 | match pspec.name() { 70 | "base-path" => { 71 | let base_path = value.get().unwrap(); 72 | self.base_path.set(base_path).unwrap(); 73 | } 74 | "remote-name" => { 75 | let remote_name = value.get().unwrap(); 76 | self.remote_name.set(remote_name).unwrap(); 77 | } 78 | _ => unimplemented!(), 79 | } 80 | } 81 | 82 | fn property(&self, _obj: &Self::Type, _id: usize, pspec: &glib::ParamSpec) -> glib::Value { 83 | match pspec.name() { 84 | "base-path" => self.base_path.get().to_value(), 85 | "remote-name" => self.remote_name.get().to_value(), 86 | _ => unimplemented!(), 87 | } 88 | } 89 | 90 | fn constructed(&self, obj: &Self::Type) { 91 | self.parent_constructed(obj); 92 | 93 | obj.setup(); 94 | } 95 | } 96 | } 97 | 98 | glib::wrapper! { 99 | pub struct RepositoryWatcher(ObjectSubclass); 100 | } 101 | 102 | impl RepositoryWatcher { 103 | pub fn new(base_path: &gio::File, remote_name: &str) -> Self { 104 | glib::Object::new(&[("base-path", &base_path), ("remote-name", &remote_name)]) 105 | .expect("Failed to create RepositoryWatcher.") 106 | } 107 | 108 | pub fn connect_remote_changed(&self, f: F) -> glib::SignalHandlerId 109 | where 110 | F: Fn(&Self) + 'static, 111 | { 112 | self.connect_local("remote-changed", true, move |values| { 113 | let obj = values[0].get::().unwrap(); 114 | f(&obj); 115 | None 116 | }) 117 | } 118 | 119 | fn base_path(&self) -> gio::File { 120 | self.property("base-path") 121 | } 122 | 123 | fn remote_name(&self) -> String { 124 | self.property("remote-name") 125 | } 126 | 127 | fn setup(&self) { 128 | let (sender, receiver) = glib::MainContext::channel(glib::PRIORITY_DEFAULT_IDLE); 129 | 130 | let base_path = self.base_path().path().unwrap(); 131 | let remote_name = self.remote_name(); 132 | 133 | // FIXME join and end the thread properly when `self` is dropped 134 | thread::spawn(move || match Repository::open(&base_path) { 135 | Ok(repo) => { 136 | log::info!("Starting watcher thread..."); 137 | 138 | loop { 139 | repo.fetch(&remote_name).unwrap_or_else(|err| { 140 | log::error!("Failed to fetch to origin: {:?}", err); 141 | }); 142 | if let Ok(is_same) = repo.is_same("HEAD", "FETCH_HEAD") { 143 | sender.send(is_same).unwrap_or_else(|err| { 144 | log::error!("Failed to send message to channel: {:?}", err); 145 | }); 146 | } else { 147 | log::error!("Failed to compare HEAD from FETCH_HEAD"); 148 | } 149 | thread::sleep(Duration::from_secs(DEFAULT_SLEEP_TIME_SECS)); 150 | } 151 | } 152 | Err(err) => { 153 | log::error!( 154 | "Failed to open repo with path `{}`: {:?}", 155 | base_path.display(), 156 | err 157 | ); 158 | } 159 | }); 160 | 161 | receiver.attach( 162 | None, 163 | clone!(@weak self as obj => @default-return Continue(false), move |is_same| { 164 | if !is_same { 165 | obj.emit_by_name::<()>("remote-changed", &[]); 166 | } 167 | Continue(true) 168 | }), 169 | ); 170 | } 171 | } 172 | -------------------------------------------------------------------------------- /src/session/content/mod.rs: -------------------------------------------------------------------------------- 1 | mod attachment_view; 2 | mod view; 3 | 4 | use gtk::{glib, prelude::*, subclass::prelude::*}; 5 | 6 | use std::cell::{Cell, RefCell}; 7 | 8 | use self::{attachment_view::AttachmentView, view::View}; 9 | use crate::model::Note; 10 | 11 | mod imp { 12 | use super::*; 13 | use gtk::CompositeTemplate; 14 | use once_cell::sync::Lazy; 15 | 16 | #[derive(Debug, Default, CompositeTemplate)] 17 | #[template(resource = "/io/github/seadve/Noteworthy/ui/content.ui")] 18 | pub struct Content { 19 | #[template_child] 20 | pub stack: TemplateChild, 21 | #[template_child] 22 | pub view_flap: TemplateChild, 23 | #[template_child] 24 | pub attachment_view: TemplateChild, 25 | #[template_child] 26 | pub no_selected_view: TemplateChild, 27 | #[template_child] 28 | pub edit_tags_button: TemplateChild, 29 | #[template_child] 30 | pub is_pinned_button: TemplateChild, 31 | #[template_child] 32 | pub is_trashed_button: TemplateChild, 33 | #[template_child] 34 | pub view_flap_button: TemplateChild, 35 | 36 | pub compact: Cell, 37 | pub note: RefCell>, 38 | 39 | pub bindings: RefCell>, 40 | } 41 | 42 | #[glib::object_subclass] 43 | impl ObjectSubclass for Content { 44 | const NAME: &'static str = "NwtyContent"; 45 | type Type = super::Content; 46 | type ParentType = gtk::Widget; 47 | 48 | fn class_init(klass: &mut Self::Class) { 49 | View::static_type(); 50 | Self::bind_template(klass); 51 | } 52 | 53 | fn instance_init(obj: &glib::subclass::InitializingObject) { 54 | obj.init_template(); 55 | } 56 | } 57 | 58 | impl ObjectImpl for Content { 59 | fn properties() -> &'static [glib::ParamSpec] { 60 | static PROPERTIES: Lazy> = Lazy::new(|| { 61 | vec![ 62 | glib::ParamSpecBoolean::new( 63 | "compact", 64 | "Compact", 65 | "Whether it is compact view mode", 66 | false, 67 | glib::ParamFlags::READWRITE, 68 | ), 69 | glib::ParamSpecObject::new( 70 | "note", 71 | "Note", 72 | "Current note in the view", 73 | Note::static_type(), 74 | glib::ParamFlags::READWRITE | glib::ParamFlags::EXPLICIT_NOTIFY, 75 | ), 76 | ] 77 | }); 78 | PROPERTIES.as_ref() 79 | } 80 | 81 | fn set_property( 82 | &self, 83 | obj: &Self::Type, 84 | _id: usize, 85 | value: &glib::Value, 86 | pspec: &glib::ParamSpec, 87 | ) { 88 | match pspec.name() { 89 | "compact" => { 90 | let compact = value.get().unwrap(); 91 | self.compact.set(compact); 92 | } 93 | "note" => { 94 | let note = value.get().unwrap(); 95 | obj.set_note(note); 96 | } 97 | _ => unimplemented!(), 98 | } 99 | } 100 | 101 | fn property(&self, obj: &Self::Type, _id: usize, pspec: &glib::ParamSpec) -> glib::Value { 102 | match pspec.name() { 103 | "compact" => self.compact.get().to_value(), 104 | "note" => obj.note().to_value(), 105 | _ => unimplemented!(), 106 | } 107 | } 108 | 109 | fn constructed(&self, obj: &Self::Type) { 110 | self.parent_constructed(obj); 111 | 112 | obj.update_buttons_visibility(); 113 | obj.update_stack(); 114 | } 115 | 116 | fn dispose(&self, obj: &Self::Type) { 117 | while let Some(child) = obj.first_child() { 118 | child.unparent(); 119 | } 120 | } 121 | } 122 | 123 | impl WidgetImpl for Content {} 124 | } 125 | 126 | glib::wrapper! { 127 | pub struct Content(ObjectSubclass) 128 | @extends gtk::Widget; 129 | } 130 | 131 | impl Content { 132 | pub fn new() -> Self { 133 | glib::Object::new(&[]).expect("Failed to create Content.") 134 | } 135 | 136 | pub fn note(&self) -> Option { 137 | self.imp().note.borrow().clone() 138 | } 139 | 140 | pub fn set_note(&self, note: Option) { 141 | if self.note() == note { 142 | return; 143 | } 144 | 145 | let imp = self.imp(); 146 | 147 | for binding in imp.bindings.borrow_mut().drain(..) { 148 | binding.unbind(); 149 | } 150 | 151 | if let Some(ref note) = note { 152 | let mut bindings = imp.bindings.borrow_mut(); 153 | let note_metadata = note.metadata(); 154 | 155 | let is_pinned = note_metadata 156 | .bind_property("is-pinned", &imp.is_pinned_button.get(), "active") 157 | .flags(glib::BindingFlags::SYNC_CREATE | glib::BindingFlags::BIDIRECTIONAL) 158 | .build(); 159 | bindings.push(is_pinned); 160 | 161 | let is_trashed = note_metadata 162 | .bind_property("is-trashed", &imp.is_trashed_button.get(), "active") 163 | .flags(glib::BindingFlags::SYNC_CREATE | glib::BindingFlags::BIDIRECTIONAL) 164 | .build(); 165 | bindings.push(is_trashed); 166 | } 167 | 168 | imp.note.replace(note); 169 | self.notify("note"); 170 | 171 | self.update_buttons_visibility(); 172 | self.update_stack(); 173 | } 174 | 175 | fn update_stack(&self) { 176 | let imp = self.imp(); 177 | 178 | if self.note().is_some() { 179 | imp.stack.set_visible_child(&imp.view_flap.get()); 180 | } else { 181 | imp.stack.set_visible_child(&imp.no_selected_view.get()); 182 | } 183 | } 184 | 185 | fn update_buttons_visibility(&self) { 186 | let imp = self.imp(); 187 | let has_note = self.note().is_some(); 188 | 189 | imp.is_pinned_button.set_visible(has_note); 190 | imp.is_trashed_button.set_visible(has_note); 191 | imp.edit_tags_button.set_visible(has_note); 192 | imp.view_flap_button.set_visible(has_note); 193 | } 194 | } 195 | -------------------------------------------------------------------------------- /src/session/note_tag_dialog/row.rs: -------------------------------------------------------------------------------- 1 | use gtk::{ 2 | glib::{self, clone}, 3 | prelude::*, 4 | subclass::prelude::*, 5 | }; 6 | 7 | use std::cell::RefCell; 8 | 9 | use super::{NoteTagLists, Tag}; 10 | 11 | mod imp { 12 | use super::*; 13 | use gtk::CompositeTemplate; 14 | use once_cell::sync::Lazy; 15 | 16 | #[derive(Debug, Default, CompositeTemplate)] 17 | #[template(resource = "/io/github/seadve/Noteworthy/ui/note-tag-dialog-row.ui")] 18 | pub struct Row { 19 | #[template_child] 20 | pub label: TemplateChild, 21 | #[template_child] 22 | pub check_button: TemplateChild, 23 | 24 | pub other_tag_lists: RefCell, 25 | pub tag: RefCell>, 26 | } 27 | 28 | #[glib::object_subclass] 29 | impl ObjectSubclass for Row { 30 | const NAME: &'static str = "NwtyNoteTagDialogRow"; 31 | type Type = super::Row; 32 | type ParentType = gtk::Widget; 33 | 34 | fn class_init(klass: &mut Self::Class) { 35 | Self::bind_template(klass); 36 | } 37 | 38 | fn instance_init(obj: &glib::subclass::InitializingObject) { 39 | obj.init_template(); 40 | } 41 | } 42 | 43 | impl ObjectImpl for Row { 44 | fn properties() -> &'static [glib::ParamSpec] { 45 | static PROPERTIES: Lazy> = Lazy::new(|| { 46 | vec![ 47 | glib::ParamSpecBoxed::new( 48 | "other-tag-lists", 49 | "A list of other tag lists", 50 | "The tag lists to compare with", 51 | NoteTagLists::static_type(), 52 | glib::ParamFlags::READWRITE | glib::ParamFlags::CONSTRUCT_ONLY, 53 | ), 54 | glib::ParamSpecObject::new( 55 | "tag", 56 | "tag", 57 | "The tag represented by this row", 58 | Tag::static_type(), 59 | glib::ParamFlags::READWRITE | glib::ParamFlags::EXPLICIT_NOTIFY, 60 | ), 61 | ] 62 | }); 63 | PROPERTIES.as_ref() 64 | } 65 | 66 | fn set_property( 67 | &self, 68 | obj: &Self::Type, 69 | _id: usize, 70 | value: &glib::Value, 71 | pspec: &glib::ParamSpec, 72 | ) { 73 | match pspec.name() { 74 | "other-tag-lists" => { 75 | let other_tag_lists = value.get().unwrap(); 76 | self.other_tag_lists.replace(other_tag_lists); 77 | } 78 | "tag" => { 79 | let tag = value.get().unwrap(); 80 | obj.set_tag(tag); 81 | } 82 | _ => unimplemented!(), 83 | } 84 | } 85 | 86 | fn property(&self, obj: &Self::Type, _id: usize, pspec: &glib::ParamSpec) -> glib::Value { 87 | match pspec.name() { 88 | "other-tag-lists" => self.other_tag_lists.borrow().to_value(), 89 | "tag" => obj.tag().to_value(), 90 | _ => unimplemented!(), 91 | } 92 | } 93 | 94 | fn constructed(&self, obj: &Self::Type) { 95 | self.parent_constructed(obj); 96 | 97 | obj.setup_signals(); 98 | } 99 | 100 | fn dispose(&self, obj: &Self::Type) { 101 | while let Some(child) = obj.first_child() { 102 | child.unparent(); 103 | } 104 | } 105 | } 106 | 107 | impl WidgetImpl for Row {} 108 | } 109 | 110 | glib::wrapper! { 111 | pub struct Row(ObjectSubclass) 112 | @extends gtk::Widget; 113 | } 114 | 115 | impl Row { 116 | pub fn new(other_tag_lists: &NoteTagLists) -> Self { 117 | glib::Object::new(&[("other-tag-lists", other_tag_lists)]).expect("Failed to create Row") 118 | } 119 | 120 | pub fn tag(&self) -> Option { 121 | self.imp().tag.borrow().clone() 122 | } 123 | 124 | pub fn set_tag(&self, tag: Option) { 125 | if let Some(ref tag) = tag { 126 | self.update_check_button_state(tag); 127 | } 128 | 129 | self.imp().tag.replace(tag); 130 | self.notify("tag"); 131 | } 132 | 133 | fn other_tag_lists(&self) -> NoteTagLists { 134 | self.property("other-tag-lists") 135 | } 136 | 137 | fn update_check_button_state(&self, tag: &Tag) { 138 | let imp = self.imp(); 139 | 140 | let other_tag_lists = self.other_tag_lists(); 141 | 142 | if other_tag_lists.is_empty() { 143 | // Basically impossible to get empty other_tag_lists from the ui 144 | log::error!("Other tag lists found to be empty"); 145 | imp.check_button.set_active(false); 146 | return; 147 | } 148 | 149 | let is_first_contains_tag = other_tag_lists.first().unwrap().contains(tag); 150 | let is_all_equal = other_tag_lists 151 | .iter() 152 | .all(|other| is_first_contains_tag == other.contains(tag)); 153 | 154 | if is_all_equal { 155 | imp.check_button.set_active(is_first_contains_tag); 156 | } else { 157 | imp.check_button.set_inconsistent(true); 158 | } 159 | } 160 | 161 | fn setup_signals(&self) { 162 | // FIXME This get activated on first launch which makes it try to append an 163 | // existing tag 164 | self.imp().check_button.connect_active_notify( 165 | clone!(@weak self as obj => move |check_button| { 166 | let tag = match obj.tag() { 167 | Some(tag) => tag, 168 | None => return, 169 | }; 170 | 171 | obj.imp().check_button.set_inconsistent(false); 172 | 173 | if check_button.is_active() { 174 | obj.other_tag_lists().append_on_all(&tag); 175 | } else { 176 | obj.other_tag_lists().remove_on_all(&tag); 177 | } 178 | }), 179 | ); 180 | 181 | // TODO Implement this so clicking the row activates the checkbutton 182 | // Works well when clicking the row but when you click the button it gets activated 183 | // twice, Idk how to not let the click pass through both widgets 184 | // let gesture = gtk::GestureClick::new(); 185 | // gesture.connect_pressed(clone!(@weak self as obj => move |_, _, _, _| { 186 | // obj.imp(); 187 | // imp.check_button.activate(); 188 | // })); 189 | // self.add_controller(&gesture); 190 | } 191 | } 192 | -------------------------------------------------------------------------------- /src/window.rs: -------------------------------------------------------------------------------- 1 | use adw::subclass::prelude::*; 2 | use gtk::{ 3 | gio, 4 | glib::{self, clone}, 5 | prelude::*, 6 | subclass::prelude::*, 7 | }; 8 | use once_cell::unsync::OnceCell; 9 | 10 | use crate::{config::PROFILE, session::Session, setup::Setup, spawn, utils, Application}; 11 | 12 | mod imp { 13 | use super::*; 14 | use gtk::CompositeTemplate; 15 | 16 | #[derive(Debug, Default, CompositeTemplate)] 17 | #[template(resource = "/io/github/seadve/Noteworthy/ui/window.ui")] 18 | pub struct Window { 19 | #[template_child] 20 | pub main_stack: TemplateChild, 21 | #[template_child] 22 | pub setup: TemplateChild, 23 | #[template_child] 24 | pub loading: TemplateChild, 25 | 26 | pub session: OnceCell, 27 | } 28 | 29 | #[glib::object_subclass] 30 | impl ObjectSubclass for Window { 31 | const NAME: &'static str = "NwtyWindow"; 32 | type Type = super::Window; 33 | type ParentType = adw::ApplicationWindow; 34 | 35 | fn class_init(klass: &mut Self::Class) { 36 | Self::bind_template(klass); 37 | 38 | klass.install_action("win.toggle-fullscreen", None, move |obj, _, _| { 39 | obj.on_toggle_fullscreen(); 40 | }); 41 | } 42 | 43 | fn instance_init(obj: &glib::subclass::InitializingObject) { 44 | obj.init_template(); 45 | } 46 | } 47 | 48 | impl ObjectImpl for Window { 49 | fn constructed(&self, obj: &Self::Type) { 50 | self.parent_constructed(obj); 51 | 52 | if PROFILE == "Devel" { 53 | obj.add_css_class("devel"); 54 | } 55 | 56 | obj.load_window_size(); 57 | 58 | self.setup 59 | .connect_session_setup_done(clone!(@weak obj => move |_, session| { 60 | spawn!(async move { 61 | if let Err(err) = obj.load_session(session).await { 62 | log::error!("Failed to load session: {:?}", err); 63 | } 64 | }); 65 | })); 66 | 67 | // If already setup 68 | if utils::default_notes_dir().exists() { 69 | let notes_folder = gio::File::for_path(utils::default_notes_dir()); 70 | spawn!(clone!(@weak obj => async move { 71 | // FIXME detect if it is offline mode or online 72 | let existing_session = Session::new_offline(¬es_folder).await; 73 | if let Err(err) = obj.load_session(existing_session).await { 74 | log::error!("Failed to load session: {:?}", err); 75 | } 76 | })); 77 | } 78 | } 79 | } 80 | 81 | impl WidgetImpl for Window {} 82 | 83 | impl WindowImpl for Window { 84 | fn close_request(&self, obj: &Self::Type) -> gtk::Inhibit { 85 | if let Err(err) = obj.save_window_size() { 86 | log::warn!("Failed to save window state: {:?}", &err); 87 | } 88 | 89 | // TODO what if app crashed? so maybe implement autosync 90 | if let Some(session) = self.session.get() { 91 | let ctx = glib::MainContext::default(); 92 | ctx.block_on(async move { 93 | if let Err(err) = session.sync().await { 94 | log::error!("Failed to sync session: {:?}", err); 95 | } 96 | }); 97 | } 98 | 99 | self.parent_close_request(obj) 100 | } 101 | } 102 | 103 | impl ApplicationWindowImpl for Window {} 104 | impl AdwApplicationWindowImpl for Window {} 105 | } 106 | 107 | glib::wrapper! { 108 | pub struct Window(ObjectSubclass) 109 | @extends gtk::Widget, gtk::Window, gtk::ApplicationWindow, adw::ApplicationWindow, 110 | @implements gio::ActionMap, gio::ActionGroup, gtk::Root; 111 | } 112 | 113 | impl Window { 114 | pub fn new(app: &Application) -> Self { 115 | glib::Object::new(&[("application", app)]).expect("Failed to create Window.") 116 | } 117 | 118 | pub fn session(&self) -> &Session { 119 | self.imp().session.get().expect("Call load_session first") 120 | } 121 | 122 | pub fn add_page(&self, page: &impl IsA) { 123 | self.imp().main_stack.add_child(page); 124 | } 125 | 126 | pub fn has_page(&self, page_to_find: &impl IsA) -> bool { 127 | // FIXME use `main_stack.page(page_to_find).is_some()` 128 | // but for some reason Stack::page is not nullable 129 | for page in self.imp().main_stack.pages().snapshot() { 130 | let child = page.downcast_ref::().unwrap().child(); 131 | 132 | if &child == page_to_find.upcast_ref() { 133 | return true; 134 | } 135 | } 136 | 137 | false 138 | } 139 | 140 | pub fn remove_page(&self, page: &impl IsA) { 141 | self.imp().main_stack.remove(page); 142 | } 143 | 144 | pub fn set_visible_page(&self, page: &impl IsA) { 145 | self.imp().main_stack.set_visible_child(page); 146 | } 147 | 148 | pub fn switch_to_session_page(&self) { 149 | self.set_visible_page(self.session()); 150 | } 151 | 152 | fn switch_to_loading_page(&self) { 153 | self.set_visible_page(&self.imp().loading.get()); 154 | } 155 | 156 | async fn load_session(&self, session: Session) -> anyhow::Result<()> { 157 | let imp = self.imp(); 158 | imp.main_stack.add_child(&session); 159 | imp.session.set(session).unwrap(); 160 | 161 | let session = self.session(); 162 | 163 | self.switch_to_loading_page(); 164 | session.load().await?; 165 | self.switch_to_session_page(); 166 | session.sync().await?; 167 | 168 | Ok(()) 169 | } 170 | 171 | fn save_window_size(&self) -> Result<(), glib::BoolError> { 172 | let settings = Application::default().settings(); 173 | 174 | let (width, height) = self.default_size(); 175 | 176 | settings.set_int("window-width", width)?; 177 | settings.set_int("window-height", height)?; 178 | 179 | settings.set_boolean("is-maximized", self.is_maximized())?; 180 | 181 | Ok(()) 182 | } 183 | 184 | fn load_window_size(&self) { 185 | let settings = Application::default().settings(); 186 | 187 | let width = settings.int("window-width"); 188 | let height = settings.int("window-height"); 189 | let is_maximized = settings.boolean("is-maximized"); 190 | 191 | self.set_default_size(width, height); 192 | 193 | if is_maximized { 194 | self.maximize(); 195 | } 196 | } 197 | 198 | fn on_toggle_fullscreen(&self) { 199 | if self.is_fullscreened() { 200 | self.unfullscreen(); 201 | } else { 202 | self.fullscreen(); 203 | } 204 | } 205 | } 206 | -------------------------------------------------------------------------------- /src/session/tag_editor/mod.rs: -------------------------------------------------------------------------------- 1 | mod row; 2 | 3 | use adw::subclass::prelude::*; 4 | use gtk::{ 5 | gio, 6 | glib::{self, clone, closure}, 7 | prelude::*, 8 | subclass::prelude::*, 9 | }; 10 | use once_cell::unsync::OnceCell; 11 | 12 | use self::row::Row; 13 | use crate::model::{NoteList, Tag, TagList}; 14 | 15 | mod imp { 16 | use super::*; 17 | use gtk::CompositeTemplate; 18 | use once_cell::sync::Lazy; 19 | 20 | #[derive(Debug, Default, CompositeTemplate)] 21 | #[template(resource = "/io/github/seadve/Noteworthy/ui/tag-editor.ui")] 22 | pub struct TagEditor { 23 | #[template_child] 24 | pub list_view: TemplateChild, 25 | #[template_child] 26 | pub search_entry: TemplateChild, 27 | #[template_child] 28 | pub create_tag_entry: TemplateChild, 29 | 30 | pub tag_list: OnceCell, 31 | pub note_list: OnceCell, 32 | } 33 | 34 | #[glib::object_subclass] 35 | impl ObjectSubclass for TagEditor { 36 | const NAME: &'static str = "NwtyTagEditor"; 37 | type Type = super::TagEditor; 38 | type ParentType = adw::Window; 39 | 40 | fn class_init(klass: &mut Self::Class) { 41 | Row::static_type(); 42 | Self::bind_template(klass); 43 | 44 | klass.install_action("tag-editor.create-tag", None, move |obj, _, _| { 45 | obj.on_create_tag(); 46 | }); 47 | } 48 | 49 | fn instance_init(obj: &glib::subclass::InitializingObject) { 50 | obj.init_template(); 51 | } 52 | } 53 | 54 | impl ObjectImpl for TagEditor { 55 | fn properties() -> &'static [glib::ParamSpec] { 56 | static PROPERTIES: Lazy> = Lazy::new(|| { 57 | vec![ 58 | glib::ParamSpecObject::new( 59 | "tag-list", 60 | "Tag List", 61 | "List of tags", 62 | TagList::static_type(), 63 | glib::ParamFlags::WRITABLE | glib::ParamFlags::CONSTRUCT_ONLY, 64 | ), 65 | glib::ParamSpecObject::new( 66 | "note-list", 67 | "Note List", 68 | "List of notes", 69 | NoteList::static_type(), 70 | glib::ParamFlags::WRITABLE | glib::ParamFlags::CONSTRUCT_ONLY, 71 | ), 72 | ] 73 | }); 74 | PROPERTIES.as_ref() 75 | } 76 | 77 | fn set_property( 78 | &self, 79 | obj: &Self::Type, 80 | _id: usize, 81 | value: &glib::Value, 82 | pspec: &glib::ParamSpec, 83 | ) { 84 | match pspec.name() { 85 | "tag-list" => { 86 | let tag_list = value.get().unwrap(); 87 | obj.set_tag_list(tag_list); 88 | } 89 | "note-list" => { 90 | let note_list = value.get().unwrap(); 91 | obj.set_note_list(note_list); 92 | } 93 | _ => unimplemented!(), 94 | } 95 | } 96 | 97 | fn property(&self, obj: &Self::Type, _id: usize, pspec: &glib::ParamSpec) -> glib::Value { 98 | match pspec.name() { 99 | "tag-list" => obj.tag_list().to_value(), 100 | "note-list" => obj.note_list().to_value(), 101 | _ => unimplemented!(), 102 | } 103 | } 104 | 105 | fn constructed(&self, obj: &Self::Type) { 106 | self.parent_constructed(obj); 107 | 108 | obj.action_set_enabled("tag-editor.create-tag", false); 109 | 110 | obj.setup_signals(); 111 | } 112 | } 113 | 114 | impl WidgetImpl for TagEditor {} 115 | impl WindowImpl for TagEditor {} 116 | impl AdwWindowImpl for TagEditor {} 117 | } 118 | 119 | glib::wrapper! { 120 | pub struct TagEditor(ObjectSubclass) 121 | @extends gtk::Widget, gtk::Window, adw::Window, 122 | @implements gio::ActionMap, gio::ActionGroup, gtk::Root; 123 | } 124 | 125 | impl TagEditor { 126 | pub fn new(tag_list: &TagList, note_list: &NoteList) -> Self { 127 | glib::Object::new(&[("tag-list", tag_list), ("note-list", note_list)]) 128 | .expect("Failed to create TagEditor.") 129 | } 130 | 131 | pub fn tag_list(&self) -> TagList { 132 | self.imp().tag_list.get().unwrap().clone() 133 | } 134 | 135 | pub fn note_list(&self) -> NoteList { 136 | self.imp().note_list.get().unwrap().clone() 137 | } 138 | 139 | fn set_tag_list(&self, tag_list: TagList) { 140 | let imp = self.imp(); 141 | 142 | let tag_name_expression = gtk::ClosureExpression::new::( 143 | &[], 144 | closure!(|tag: Tag| tag.name()), 145 | ); 146 | let filter = gtk::StringFilter::builder() 147 | .match_mode(gtk::StringFilterMatchMode::Substring) 148 | .expression(&tag_name_expression) 149 | .ignore_case(true) 150 | .build(); 151 | let filter_model = gtk::FilterListModel::new(Some(&tag_list), Some(&filter)); 152 | 153 | imp.search_entry 154 | .bind_property("text", &filter, "search") 155 | .flags(glib::BindingFlags::SYNC_CREATE) 156 | .build(); 157 | 158 | let selection_model = gtk::NoSelection::new(Some(&filter_model)); 159 | imp.list_view.set_model(Some(&selection_model)); 160 | 161 | imp.tag_list.set(tag_list).unwrap(); 162 | } 163 | 164 | fn set_note_list(&self, note_list: NoteList) { 165 | self.imp().note_list.set(note_list).unwrap(); 166 | } 167 | 168 | fn on_create_tag(&self) { 169 | let imp = self.imp(); 170 | let name = imp.create_tag_entry.text(); 171 | 172 | let tag_list = self.tag_list(); 173 | tag_list.append(Tag::new(&name)).unwrap(); 174 | 175 | imp.create_tag_entry.set_text(""); 176 | } 177 | 178 | fn setup_signals(&self) { 179 | let imp = self.imp(); 180 | 181 | imp.create_tag_entry 182 | .connect_text_notify(clone!(@weak self as obj => move |entry| { 183 | let imp = obj.imp(); 184 | 185 | if obj.tag_list().is_valid_name(&entry.text()) { 186 | obj.action_set_enabled("tag-editor.create-tag", true); 187 | imp.create_tag_entry.remove_css_class("error"); 188 | } else { 189 | obj.action_set_enabled("tag-editor.create-tag", false); 190 | imp.create_tag_entry.add_css_class("error"); 191 | } 192 | })); 193 | 194 | imp.create_tag_entry 195 | .connect_activate(clone!(@weak self as obj => move |_| { 196 | WidgetExt::activate_action(&obj, "tag-editor.create-tag", None).unwrap(); 197 | })); 198 | } 199 | } 200 | -------------------------------------------------------------------------------- /src/session/content/attachment_view/audio_recorder_button.rs: -------------------------------------------------------------------------------- 1 | use adw::{prelude::*, subclass::prelude::*}; 2 | use gtk::{ 3 | gio, 4 | glib::{self, clone}, 5 | subclass::prelude::*, 6 | }; 7 | use once_cell::unsync::OnceCell; 8 | 9 | use crate::{ 10 | core::AudioRecorder, 11 | session::Session, 12 | spawn, 13 | widgets::{AudioVisualizer, TimeLabel}, 14 | }; 15 | 16 | mod imp { 17 | use super::*; 18 | use glib::subclass::Signal; 19 | use gtk::CompositeTemplate; 20 | use once_cell::sync::Lazy; 21 | 22 | #[derive(Debug, Default, CompositeTemplate)] 23 | #[template( 24 | resource = "/io/github/seadve/Noteworthy/ui/content-attachment-view-audio-recorder-button.ui" 25 | )] 26 | pub struct AudioRecorderButton { 27 | #[template_child] 28 | pub menu_button: TemplateChild, 29 | #[template_child] 30 | pub popover: TemplateChild, 31 | #[template_child] 32 | pub visualizer: TemplateChild, 33 | #[template_child] 34 | pub duration_label: TemplateChild, 35 | 36 | pub recorder: AudioRecorder, 37 | pub popover_closed_handler_id: OnceCell, 38 | } 39 | 40 | #[glib::object_subclass] 41 | impl ObjectSubclass for AudioRecorderButton { 42 | const NAME: &'static str = "NwtyContentAttachmentViewAudioRecorderButton"; 43 | type Type = super::AudioRecorderButton; 44 | type ParentType = adw::Bin; 45 | 46 | fn class_init(klass: &mut Self::Class) { 47 | Self::bind_template(klass); 48 | 49 | klass.install_action("audio-recorder-button.record-ok", None, move |obj, _, _| { 50 | obj.stop_recording(); 51 | 52 | let imp = obj.imp(); 53 | let popover_closed_handler_id = imp.popover_closed_handler_id.get().unwrap(); 54 | imp.popover.block_signal(popover_closed_handler_id); 55 | imp.menu_button.popdown(); 56 | imp.popover.unblock_signal(popover_closed_handler_id); 57 | }); 58 | } 59 | 60 | fn instance_init(obj: &glib::subclass::InitializingObject) { 61 | obj.init_template(); 62 | } 63 | } 64 | 65 | impl ObjectImpl for AudioRecorderButton { 66 | fn signals() -> &'static [Signal] { 67 | static SIGNALS: Lazy> = Lazy::new(|| { 68 | vec![ 69 | Signal::builder("on-record", &[], <()>::static_type().into()).build(), 70 | Signal::builder( 71 | "record-done", 72 | &[gio::File::static_type().into()], 73 | <()>::static_type().into(), 74 | ) 75 | .build(), 76 | ] 77 | }); 78 | SIGNALS.as_ref() 79 | } 80 | 81 | fn constructed(&self, obj: &Self::Type) { 82 | self.parent_constructed(obj); 83 | 84 | obj.setup_signals(); 85 | } 86 | } 87 | 88 | impl WidgetImpl for AudioRecorderButton {} 89 | impl BinImpl for AudioRecorderButton {} 90 | } 91 | 92 | glib::wrapper! { 93 | pub struct AudioRecorderButton(ObjectSubclass) 94 | @extends gtk::Widget, adw::Bin, 95 | @implements gtk::Accessible; 96 | } 97 | 98 | impl AudioRecorderButton { 99 | pub fn new() -> Self { 100 | glib::Object::new(&[]).expect("Failed to create AudioRecorderButton") 101 | } 102 | 103 | pub fn connect_on_record(&self, f: F) -> glib::SignalHandlerId 104 | where 105 | F: Fn(&Self) + 'static, 106 | { 107 | self.connect_local("on-record", true, move |values| { 108 | let obj = values[0].get::().unwrap(); 109 | f(&obj); 110 | None 111 | }) 112 | } 113 | 114 | pub fn connect_record_done(&self, f: F) -> glib::SignalHandlerId 115 | where 116 | F: Fn(&Self, &gio::File) + 'static, 117 | { 118 | self.connect_local("record-done", true, move |values| { 119 | let obj = values[0].get::().unwrap(); 120 | let file = values[1].get::().unwrap(); 121 | f(&obj, &file); 122 | None 123 | }) 124 | } 125 | 126 | fn visualizer(&self) -> &AudioVisualizer { 127 | &self.imp().visualizer 128 | } 129 | 130 | fn duration_label(&self) -> &TimeLabel { 131 | &self.imp().duration_label 132 | } 133 | 134 | fn recorder(&self) -> &AudioRecorder { 135 | &self.imp().recorder 136 | } 137 | 138 | fn start_recording(&self) { 139 | let recording_base_path = Session::default().directory(); 140 | 141 | if let Err(err) = self.recorder().start(&recording_base_path) { 142 | log::error!("Failed to start recording: {:?}", err); 143 | return; 144 | } 145 | 146 | self.emit_by_name::<()>("on-record", &[]); 147 | 148 | log::info!("Started recording"); 149 | } 150 | 151 | fn cancel_recording(&self) { 152 | spawn!(clone!(@weak self as obj => async move { 153 | obj.recorder().cancel().await; 154 | })); 155 | 156 | self.visualizer().clear_peaks(); 157 | self.duration_label().reset(); 158 | 159 | log::info!("Cancelled recording"); 160 | } 161 | 162 | fn stop_recording(&self) { 163 | spawn!(clone!(@weak self as obj => async move { 164 | match obj.recorder().stop().await { 165 | Ok(recording) => { 166 | obj.emit_by_name::<()>("record-done", &[&recording.into_file()]); 167 | } 168 | Err(err) => { 169 | log::error!("Failed to stop recording: {:?}", err); 170 | } 171 | } 172 | })); 173 | 174 | self.visualizer().clear_peaks(); 175 | self.duration_label().reset(); 176 | 177 | log::info!("Stopped recording"); 178 | } 179 | 180 | fn setup_signals(&self) { 181 | let imp = self.imp(); 182 | 183 | imp.recorder 184 | .connect_peak_notify(clone!(@weak self as obj => move |recorder| { 185 | let peak = 10_f64.powf(recorder.peak() / 20.0); 186 | obj.visualizer().push_peak(peak as f32); 187 | })); 188 | 189 | imp.recorder 190 | .connect_duration_notify(clone!(@weak self as obj => move |recorder| { 191 | obj.duration_label().set_time(recorder.duration()); 192 | })); 193 | 194 | imp.popover 195 | .connect_show(clone!(@weak self as obj => move |_| { 196 | obj.start_recording(); 197 | })); 198 | 199 | let popover_closed_handler_id = 200 | imp.popover 201 | .connect_closed(clone!(@weak self as obj => move |_| { 202 | obj.cancel_recording(); 203 | })); 204 | imp.popover_closed_handler_id 205 | .set(popover_closed_handler_id) 206 | .unwrap(); 207 | } 208 | } 209 | --------------------------------------------------------------------------------