// SPDX-License-Identifier: GPL-3.0-only use cosmic::surface; use cosmic::widget::menu::action::MenuAction; use cosmic::widget::menu::key_bind::KeyBind; use cosmic::widget::segmented_button::Entity; use cosmic::{ Application, ApplicationExt, Apply, Element, action, app::{Core, Settings, Task, context_drawer}, cosmic_config::{self, CosmicConfigEntry}, cosmic_theme, executor, font::Font, iced::{ self, Alignment, Background, Color, Length, Limits, Point, Subscription, advanced::graphics::text::font_system, clipboard, event, futures::{self, SinkExt}, keyboard::{self, Modifiers}, stream, window, }, style, theme, widget::{self, about::About, button, icon, nav_bar, segmented_button}, }; use cosmic_files::{ dialog::{Dialog, DialogKind, DialogMessage, DialogResult, DialogSettings}, mime_icon::{mime_for_path, mime_icon}, }; use cosmic_text::{Cursor, Edit, Family, Selection, SwashCache, SyntaxSystem, ViMode}; use serde::{Deserialize, Serialize}; use std::{ any::TypeId, collections::HashMap, env, fs, io, path::{Path, PathBuf}, process, sync::{Mutex, OnceLock}, }; use tokio::time; use unicode_segmentation::UnicodeSegmentation; use config::{AppTheme, CONFIG_VERSION, Config, ConfigState}; mod config; use git::{GitDiff, GitDiffLine, GitRepository, GitStatus, GitStatusKind}; mod git; use icon_cache::IconCache; mod icon_cache; use key_bind::key_binds; mod key_bind; use line_number::LineNumberCache; mod line_number; mod localize; use self::menu::menu_bar; mod menu; use self::project::ProjectNode; mod project; use self::search::ProjectSearchResult; mod search; use self::tab::{EditorTab, GitDiffTab, Tab}; mod tab; use self::text_box::text_box; mod text_box; static ICON_CACHE: OnceLock> = OnceLock::new(); static LINE_NUMBER_CACHE: OnceLock> = OnceLock::new(); static SWASH_CACHE: OnceLock> = OnceLock::new(); static SYNTAX_SYSTEM: OnceLock = OnceLock::new(); pub fn icon_cache_get(name: &'static str, size: u16) -> icon::Icon { let mut icon_cache = ICON_CACHE.get().unwrap().lock().unwrap(); icon_cache.get(name, size) } /// Creates monospace attributes for text rendering. /// This centralizes the creation of monospace font attributes to avoid duplication. pub fn monospace_attrs() -> cosmic_text::Attrs<'static> { cosmic_text::Attrs::new().family(Family::Monospace) } fn main() -> Result<(), Box> { #[cfg(all(unix, not(target_os = "redox")))] match fork::daemon(true, true) { Ok(fork::Fork::Child) => (), Ok(fork::Fork::Parent(_child_pid)) => process::exit(0), Err(err) => { eprintln!("failed to daemonize: {:?}", err); process::exit(1); } } ICON_CACHE.get_or_init(|| Mutex::new(IconCache::new())); LINE_NUMBER_CACHE.get_or_init(|| Mutex::new(LineNumberCache::new())); SWASH_CACHE.get_or_init(|| Mutex::new(SwashCache::new())); SYNTAX_SYSTEM.get_or_init(|| { let lazy_theme_set = two_face::theme::LazyThemeSet::from(two_face::theme::extra()); let mut theme_set = syntect::highlighting::ThemeSet::from(&lazy_theme_set); // Hardcoded COSMIC themes for (theme_name, theme_data) in &[ ("COSMIC Dark", cosmic_syntax_theme::COSMIC_DARK_TM_THEME), ("COSMIC Light", cosmic_syntax_theme::COSMIC_LIGHT_TM_THEME), ] { let mut cursor = io::Cursor::new(theme_data); match syntect::highlighting::ThemeSet::load_from_reader(&mut cursor) { Ok(mut theme) => { // Use libcosmic theme for background and gutter theme.settings.background = Some(syntect::highlighting::Color { r: 0, g: 0, b: 0, a: 0, }); theme.settings.gutter = Some(syntect::highlighting::Color { r: 0, g: 0, b: 0, a: 0, }); theme_set.themes.insert(theme_name.to_string(), theme); } Err(err) => { eprintln!("failed to load {:?} syntax theme: {}", theme_name, err); } } } SyntaxSystem { //TODO: store newlines in buffer syntax_set: two_face::syntax::extra_no_newlines(), theme_set, } }); env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("warn")).init(); localize::localize(); let (config_handler, config) = match cosmic_config::Config::new(App::APP_ID, CONFIG_VERSION) { Ok(config_handler) => { let config = Config::get_entry(&config_handler).unwrap_or_else(|(errs, config)| { log::info!("errors loading config: {:?}", errs); config }); (Some(config_handler), config) } Err(err) => { log::error!("failed to create config handler: {}", err); (None, Config::default()) } }; let (config_state_handler, config_state) = match cosmic_config::Config::new_state(App::APP_ID, CONFIG_VERSION) { Ok(config_state_handler) => { let config_state = ConfigState::get_entry(&config_state_handler).unwrap_or_else( |(errs, config_state)| { log::info!("errors loading config_state: {:?}", errs); config_state }, ); (Some(config_state_handler), config_state) } Err(err) => { log::error!("failed to create config_state handler: {}", err); (None, ConfigState::default()) } }; let mut settings = Settings::default(); settings = settings.theme(config.app_theme.theme()); settings = settings.size_limits(Limits::NONE.min_width(360.0).min_height(180.0)); settings = settings.exit_on_close(false); let flags = Flags { config_handler, config, config_state_handler, config_state, }; cosmic::app::run::(settings, flags)?; Ok(()) } #[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)] pub enum Action { Todo, About, CloseFile, CloseProject(usize), Copy, Cut, Find, FindAndReplace, NewFile, NewWindow, OpenFileDialog, OpenProjectDialog, OpenRecentFile(usize), OpenRecentProject(usize), Paste, Quit, Redo, RevertAllChanges, Save, SaveAsDialog, SelectAll, TabActivate0, TabActivate1, TabActivate2, TabActivate3, TabActivate4, TabActivate5, TabActivate6, TabActivate7, TabActivate8, TabNext, TabPrev, TabWidth(u16), ToggleAutoIndent, ToggleDocumentStatistics, ToggleGitManagement, ToggleHighlightCurrentLine, ToggleLineNumbers, ToggleProjectSearch, ToggleSettingsPage, ToggleWordWrap, Undo, ZoomIn, ZoomOut, ZoomReset, } impl Action { fn message(&self, entity_opt: Option) -> Message { match self { Self::Todo => Message::Todo, Self::About => Message::ToggleContextPage(ContextPage::About), Self::CloseFile => Message::CloseFile, Self::CloseProject(project_i) => Message::CloseProject(*project_i), Self::Copy => Message::Copy, Self::Cut => Message::Cut, Self::Find => Message::Find(Some(false)), Self::FindAndReplace => Message::Find(Some(true)), Self::NewFile => Message::NewFile, Self::NewWindow => Message::NewWindow, Self::OpenFileDialog => Message::OpenFileDialog, Self::OpenProjectDialog => Message::OpenProjectDialog, Self::OpenRecentFile(index) => Message::OpenRecentFile(*index), Self::OpenRecentProject(index) => Message::OpenRecentProject(*index), Self::Paste => Message::Paste, Self::Quit => Message::Quit, Self::Redo => Message::Redo, Self::RevertAllChanges => Message::RevertAllChanges, Self::Save => Message::Save(entity_opt), Self::SaveAsDialog => Message::SaveAsDialog(entity_opt), Self::SelectAll => Message::SelectAll, Self::TabActivate0 => Message::TabActivateJump(0), Self::TabActivate1 => Message::TabActivateJump(1), Self::TabActivate2 => Message::TabActivateJump(2), Self::TabActivate3 => Message::TabActivateJump(3), Self::TabActivate4 => Message::TabActivateJump(4), Self::TabActivate5 => Message::TabActivateJump(5), Self::TabActivate6 => Message::TabActivateJump(6), Self::TabActivate7 => Message::TabActivateJump(7), Self::TabActivate8 => Message::TabActivateJump(8), Self::TabNext => Message::TabNext, Self::TabPrev => Message::TabPrev, Self::TabWidth(tab_width) => Message::TabWidth(*tab_width), Self::ToggleAutoIndent => Message::ToggleAutoIndent, Self::ToggleDocumentStatistics => { Message::ToggleContextPage(ContextPage::DocumentStatistics) } Self::ToggleGitManagement => Message::ToggleContextPage(ContextPage::GitManagement), Self::ToggleHighlightCurrentLine => Message::ToggleHighlightCurrentLine, Self::ToggleLineNumbers => Message::ToggleLineNumbers, Self::ToggleProjectSearch => Message::ToggleContextPage(ContextPage::ProjectSearch), Self::ToggleSettingsPage => Message::ToggleContextPage(ContextPage::Settings), Self::ToggleWordWrap => Message::ToggleWordWrap, Self::Undo => Message::Undo, Self::ZoomIn => Message::ZoomIn, Self::ZoomOut => Message::ZoomOut, Self::ZoomReset => Message::ZoomReset, } } } impl MenuAction for Action { type Message = Message; fn message(&self) -> Message { self.message(None) } } #[derive(Clone, Debug)] pub struct Flags { config_handler: Option, config: Config, config_state_handler: Option, config_state: ConfigState, } #[derive(Debug)] pub struct WatcherWrapper { watcher_opt: Option, } impl Clone for WatcherWrapper { fn clone(&self) -> Self { Self { watcher_opt: None } } } impl PartialEq for WatcherWrapper { fn eq(&self, _other: &Self) -> bool { false } } enum NewTab { Tab(EditorTab), Exists(Entity), } #[allow(dead_code)] #[derive(Clone, Debug)] pub enum Message { AppTheme(AppTheme), AutoScroll(Option), Config(Config), ConfigState(ConfigState), CloseFile, CloseProject(usize), CloseWindow(window::Id), Copy, Cut, DefaultFont(usize), DefaultFontSize(usize), ZoomIn, ZoomOut, ZoomReset, DefaultZoomStep(usize), DialogCancel, DialogMessage(DialogMessage), Find(Option), FindCaseSensitive(bool), FindNext, FindPrevious, FindReplace, FindReplaceAll, FindReplaceValueChanged(String), FindSearchValueChanged(String), FindUseRegex(bool), FindWrapAround(bool), Focus(window::Id), GitProjectStatus(Vec<(String, PathBuf, Vec)>), GitStage(PathBuf, PathBuf), GitUnstage(PathBuf, PathBuf), Key(Modifiers, keyboard::Key), LaunchUrl(String), Modifiers(Modifiers), NewFile, NewWindow, NotifyEvent(notify::Event), NotifyWatcher(WatcherWrapper), OpenFile(PathBuf), OpenFileDialog, OpenFileResult(DialogResult), OpenGitDiff(PathBuf, GitDiff), OpenProjectDialog, OpenProjectResult(DialogResult), OpenRecentFile(usize), OpenRecentProject(usize), OpenSearchResult(usize, usize), Paste, PasteValue(String), PrepareGitDiff(PathBuf, PathBuf, bool), ProjectSearchResult(ProjectSearchResult), ProjectSearchSubmit, ProjectSearchValue(String), PromptSaveChanges(segmented_button::Entity), Quit, QuitForce, Redo, RevertAllChanges, Save(Option), SaveAll, SaveAsDialog(Option), SaveAsResult(segmented_button::Entity, DialogResult), Scroll(f32), SelectAll, Surface(surface::Action), SystemThemeModeChange(cosmic_theme::ThemeMode), SyntaxTheme(usize, bool), TabActivate(segmented_button::Entity), TabActivateJump(usize), TabChanged(segmented_button::Entity), TabClose(segmented_button::Entity), TabCloseForce(segmented_button::Entity), TabContextAction(segmented_button::Entity, Action), TabContextMenu(segmented_button::Entity, Option), TabNext, TabPrev, TabSetCursor(segmented_button::Entity, Cursor), TabWidth(u16), Todo, ToggleAutoIndent, ToggleContextPage(ContextPage), ToggleHighlightCurrentLine, ToggleLineNumbers, ToggleWordWrap, Undo, UpdateGitProjectStatus, VimBindings(bool), } #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum ContextPage { About, DocumentStatistics, GitManagement, //TODO: Move search to pop-up ProjectSearch, Settings, } #[derive(Clone, Debug, Eq, PartialEq)] enum DialogPage { PromptSaveClose(segmented_button::Entity), PromptSaveQuit(Vec), } #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum Find { None, Find, FindAndReplace, } pub struct App { core: Core, about: About, nav_model: segmented_button::SingleSelectModel, tab_model: segmented_button::SingleSelectModel, config_handler: Option, config: Config, config_state_handler: Option, config_state: ConfigState, zoom_step_names: Vec, zoom_steps: Vec, key_binds: HashMap, app_themes: Vec, font_names: Vec, font_size_names: Vec, font_sizes: Vec, theme_names: Vec, context_page: ContextPage, text_box_id: widget::Id, auto_scroll: Option, dialog_opt: Option>, dialog_page_opt: Option, find_opt: Option, find_replace_id: widget::Id, find_replace_value: String, find_search_id: widget::Id, find_search_value: String, git_project_status: Option)>>, projects: Vec<(String, PathBuf)>, project_search_id: widget::Id, project_search_value: String, project_search_result: Option, watcher_opt: Option, modifiers: Modifiers, } impl App { pub fn active_tab(&self) -> Option<&Tab> { self.tab_model.active_data() } pub fn active_tab_mut(&mut self) -> Option<&mut Tab> { self.tab_model.active_data_mut() } fn open_folder>(&mut self, path: P, mut position: u16, indent: u16) { let read_dir = match fs::read_dir(&path) { Ok(ok) => ok, Err(err) => { log::error!("failed to read directory {:?}: {}", path.as_ref(), err); return; } }; let mut nodes = Vec::new(); for entry_res in read_dir { let entry = match entry_res { Ok(ok) => ok, Err(err) => { log::error!( "failed to read entry in directory {:?}: {}", path.as_ref(), err ); continue; } }; let entry_path = entry.path(); let node = match ProjectNode::new(&entry_path) { Ok(ok) => ok, Err(err) => { log::error!( "failed to open directory {:?} entry {:?}: {}", path.as_ref(), entry_path, err ); continue; } }; nodes.push(node); } nodes.sort(); for node in nodes { self.nav_model .insert() .position(position) .indent(indent) .icon(node.icon(16)) .text(node.name().to_string()) .data(node); position += 1; } } pub fn open_project>(&mut self, path: P) { let path = path.as_ref(); let node = match ProjectNode::new(path) { Ok(mut node) => { match &mut node { ProjectNode::Folder { name, path, open, root, } => { *open = true; *root = true; for (_project_name, project_path) in self.projects.iter() { if project_path == path { // Project already open return; } } // Save the absolute path self.projects.push((name.to_string(), path.to_path_buf())); // Add to recent projects, ensuring only one entry self.config_state.recent_projects.retain(|x| x != path); self.config_state .recent_projects .push_front(path.to_path_buf()); self.config_state.recent_projects.truncate(10); self.save_config_state(); // Open nav bar self.core.nav_bar_set_toggled(true); } _ => { log::error!("failed to open project {:?}: not a directory", path); return; } } node } Err(err) => { log::error!("failed to open project {:?}: {}", path, err); return; } }; let id = self .nav_model .insert() .icon(node.icon(16)) .text(node.name().to_string()) .data(node) .id(); let position = self.nav_model.position(id).unwrap_or(0); self.open_folder(path, position + 1, 1); } pub fn open_tab(&mut self, path_opt: Option) -> Option { match self.new_tab(path_opt)? { NewTab::Exists(entity) => Some(entity), NewTab::Tab(tab) => Some( self.tab_model .insert() .text(tab.title()) .icon(tab.icon(16)) .data::(Tab::Editor(tab)) .closable() .activate() .id(), ), } } /// Replace existing tab, `entity`, with contents loaded from `path` pub fn replace_tab( &mut self, path: PathBuf, entity: Entity, ) -> Option { match self.new_tab(Some(path))? { NewTab::Exists(existing) => { // Swap to existing tab and remove tab keyed by `entity` self.tab_model.remove(entity); Some(existing) } NewTab::Tab(tab) => { // Replace existing tab in place self.tab_model.text_set(entity, tab.title()); self.tab_model.icon_set(entity, tab.icon(16)); self.tab_model.data_set::(entity, Tab::Editor(tab)); self.tab_model.activate(entity); Some(entity) } } } fn new_tab(&mut self, path_opt: Option) -> Option { match path_opt { Some(path) => { let canonical = match fs::canonicalize(&path) { Ok(ok) => ok, Err(err) => { log::error!("failed to canonicalize {:?}: {}", path, err); return None; } }; //TODO: allow files to be open multiple times let mut activate_opt = None; for entity in self.tab_model.iter() { if let Some(Tab::Editor(tab)) = self.tab_model.data::(entity) { if tab.path_opt.as_ref() == Some(&canonical) { activate_opt = Some(entity); break; } } } if let Some(entity) = activate_opt { self.tab_model.activate(entity); return Some(NewTab::Exists(entity)); } // Add to recent files, ensuring only one entry self.config_state.recent_files.retain(|x| x != &canonical); self.config_state .recent_files .push_front(canonical.to_path_buf()); self.config_state.recent_files.truncate(10); self.save_config_state(); let mut tab = EditorTab::new(&self.config); tab.open(canonical); tab.watch(&mut self.watcher_opt); Some(NewTab::Tab(tab)) } None => Some(NewTab::Tab(EditorTab::new(&self.config))), } } fn update_config(&mut self) -> Task { //TODO: provide iterator over data let entities: Vec<_> = self.tab_model.iter().collect(); for entity in entities { if let Some(Tab::Editor(tab)) = self.tab_model.data_mut::(entity) { tab.set_config(&self.config); } } cosmic::command::set_theme(self.config.app_theme.theme()) } fn update_render_active_tab_zoom(&mut self, zoom_message: Message) -> Task { if let Some(Tab::Editor(tab)) = self.active_tab_mut() { let current_zoom_adj = tab.zoom_adj(); match zoom_message { Message::ZoomIn => tab.set_zoom_adj(current_zoom_adj.saturating_add(1)), Message::ZoomOut => tab.set_zoom_adj(current_zoom_adj.saturating_sub(1)), _ => {} } let entities: Vec<_> = self.tab_model.iter().collect(); for entity in entities { if self.tab_model.is_active(entity) { if let Some(Tab::Editor(tab)) = self.tab_model.data_mut::(entity) { tab.set_config(&self.config); } } } } Task::none() } fn reset_tabs_zoom(&mut self) { let entities: Vec<_> = self.tab_model.iter().collect(); for entity in entities { if let Some(Tab::Editor(tab)) = self.tab_model.data_mut::(entity) { tab.set_zoom_adj(0); } } } fn save_config_state(&mut self) { if let Some(ref config_state_handler) = self.config_state_handler { if let Err(err) = self.config_state.write_entry(config_state_handler) { log::error!("failed to save config_state: {}", err); } } } fn update_dialogs(&mut self) -> Task { match self.dialog_page_opt { Some(DialogPage::PromptSaveClose(entity)) => { if let Some(Tab::Editor(tab)) = self.tab_model.data::(entity) { if !tab.changed() { // Tab has been saved, close it (which also closes this dialog) return self.update(Message::TabCloseForce(entity)); } } else { // Tab no longer found, close dialog self.dialog_page_opt = None; } } Some(DialogPage::PromptSaveQuit(ref _entities)) => { let mut unsaved = Vec::new(); for entity in self.tab_model.iter() { if let Some(Tab::Editor(tab)) = self.tab_model.data::(entity) { if tab.changed() { unsaved.push(entity); } } } if unsaved.is_empty() { // All tabs have been saved, we can exit return self.update(Message::QuitForce); } else { // Update dialog self.dialog_page_opt = Some(DialogPage::PromptSaveQuit(unsaved)); } } None => {} } Task::none() } fn update_focus(&self) -> Task { if self.core.window.show_context { match self.context_page { ContextPage::ProjectSearch => { widget::text_input::focus(self.project_search_id.clone()) } _ => Task::none(), } } else if self.find_opt.is_some() { widget::text_input::focus(self.find_search_id.clone()) } else { widget::text_input::focus(self.text_box_id.clone()) } } fn update_nav_bar_active(&mut self) { let tab_path_opt = match self.active_tab() { Some(Tab::Editor(tab)) => tab.path_opt.clone(), Some(Tab::GitDiff(tab)) => Some(tab.diff.path.clone()), None => None, }; // Locate tree node to activate let mut active_id = segmented_button::Entity::default(); if let Some(tab_path) = tab_path_opt { // Automatically expand tree to find and select active file loop { let mut expand_opt = None; for id in self.nav_model.iter() { if let Some(node) = self.nav_model.data(id) { match node { ProjectNode::Folder { path, open, .. } => { if tab_path.starts_with(path) && !*open { expand_opt = Some(id); break; } } ProjectNode::File { path, .. } => { if path == &tab_path { active_id = id; break; } } } } } match expand_opt { Some(id) => { //TODO: can this be optimized? // Task not used becuase opening a folder just returns Task::none let _ = self.on_nav_select(id); } None => { break; } } } } self.nav_model.activate(active_id); } // Call this any time the tab changes pub fn update_tab(&mut self) -> Task { self.update_nav_bar_active(); let title = match self.active_tab() { Some(tab) => { if let Tab::Editor(inner) = tab { // Force redraw on tab switches inner.editor.lock().unwrap().set_redraw(true); } tab.title() } None => "No Open File".to_string(), }; let window_title = format!("{title} - {}", fl!("cosmic-text-editor")); Task::batch([ if let Some(window_id) = self.core.main_window_id() { self.set_window_title(window_title, window_id) } else { Task::none() }, self.update_focus(), ]) } fn document_statistics(&self) -> Element<'_, Message> { //TODO: calculate in the background let mut character_count = 0; let mut character_count_no_spaces = 0; let mut line_count = 0; let mut word_count = 0; if let Some(Tab::Editor(tab)) = self.active_tab() { let editor = tab.editor.lock().unwrap(); editor.with_buffer(|buffer| { line_count = buffer.lines.len(); for line in buffer.lines.iter() { let text = line.text(); let mut last_whitespace = true; // Count graphemes instead of Unicode scalar values for accurate character count for grapheme in text.graphemes(true) { character_count += 1; let is_whitespace = grapheme.chars().all(|c| c.is_whitespace()); if !is_whitespace { character_count_no_spaces += 1; if last_whitespace { word_count += 1; } } last_whitespace = is_whitespace; } } }); } widget::settings::view_column(vec![ widget::settings::section() .add( widget::settings::item::builder(fl!("word-count")) .control(widget::text(word_count.to_string())), ) .add( widget::settings::item::builder(fl!("character-count")) .control(widget::text(character_count.to_string())), ) .add( widget::settings::item::builder(fl!("character-count-no-spaces")) .control(widget::text(character_count_no_spaces.to_string())), ) .add( widget::settings::item::builder(fl!("line-count")) .control(widget::text(line_count.to_string())), ) .into(), ]) .into() } fn git_management(&self) -> Element<'_, Message> { let spacing = self.core().system_theme().cosmic().spacing; if let Some(project_status) = &self.git_project_status { let (success_color, destructive_color, warning_color) = { let cosmic_theme = self.core().system_theme().cosmic(); ( cosmic_theme.success_color(), cosmic_theme.destructive_color(), cosmic_theme.warning_color(), ) }; let added = || widget::text("[+]").class(theme::Text::Color(success_color.into())); let deleted = || widget::text("[-]").class(theme::Text::Color(destructive_color.into())); let modified = || widget::text("[*]").class(theme::Text::Color(warning_color.into())); let mut items = Vec::with_capacity(project_status.len().saturating_mul(3).saturating_add(1)); items.push(widget::text(fl!("git-management-description")).into()); for (project_name, project_path, status) in project_status.iter() { let mut unstaged_items = Vec::with_capacity(status.len()); let mut staged_items = Vec::with_capacity(status.len()); for item in status.iter() { let relative_path = match item.path.strip_prefix(project_path) { Ok(ok) => ok, Err(err) => { log::warn!( "failed to find relative path of {:?} in project {:?}: {}", item.path, project_path, err ); &item.path } }; let text = match &item.old_path { Some(old_path) => { let old_relative_path = match old_path.strip_prefix(project_path) { Ok(ok) => ok, Err(err) => { log::warn!( "failed to find relative path of {:?} in project {:?}: {}", old_path, project_path, err ); old_path } }; format!( "{} -> {}", old_relative_path.display(), relative_path.display() ) } None => format!("{}", relative_path.display()), }; let unstaged_opt = match item.unstaged { GitStatusKind::Unmodified => None, GitStatusKind::Modified => Some(modified()), GitStatusKind::FileTypeChanged => Some(modified()), GitStatusKind::Added => Some(added()), GitStatusKind::Deleted => Some(deleted()), GitStatusKind::Renamed => Some(modified()), //TODO GitStatusKind::Copied => Some(modified()), // TODO GitStatusKind::Updated => Some(modified()), GitStatusKind::Untracked => Some(added()), GitStatusKind::SubmoduleModified => Some(modified()), }; if let Some(icon) = unstaged_opt { unstaged_items.push( widget::button::custom( widget::row::with_children(vec![ icon.into(), widget::text(text.clone()).into(), widget::horizontal_space().into(), widget::button::standard(fl!("stage")) .on_press(Message::GitStage( project_path.clone(), item.path.clone(), )) .into(), ]) .align_y(Alignment::Center) .spacing(spacing.space_xs), ) .on_press(Message::PrepareGitDiff( project_path.clone(), item.path.clone(), false, )) .class(theme::Button::AppletMenu) .width(Length::Fill) .into(), ); } let staged_opt = match item.staged { GitStatusKind::Unmodified => None, GitStatusKind::Modified => Some(modified()), GitStatusKind::FileTypeChanged => Some(modified()), GitStatusKind::Added => Some(added()), GitStatusKind::Deleted => Some(deleted()), GitStatusKind::Renamed => Some(modified()), //TODO GitStatusKind::Copied => Some(modified()), // TODO GitStatusKind::Updated => Some(modified()), GitStatusKind::Untracked => None, GitStatusKind::SubmoduleModified => Some(modified()), }; if let Some(icon) = staged_opt { staged_items.push( widget::button::custom( widget::row::with_children(vec![ icon.into(), widget::text(text.clone()).into(), widget::horizontal_space().into(), widget::button::standard(fl!("unstage")) .on_press(Message::GitUnstage( project_path.clone(), item.path.clone(), )) .into(), ]) .align_y(Alignment::Center) .spacing(spacing.space_xs), ) .on_press(Message::PrepareGitDiff( project_path.clone(), item.path.clone(), true, )) .class(theme::Button::AppletMenu) .width(Length::Fill) .into(), ); } } items.push(widget::text::heading(project_name.clone()).into()); if !unstaged_items.is_empty() { items.push( widget::settings::section() .title(fl!("unstaged-changes")) .add(widget::column::with_children(unstaged_items)) .into(), ); } if !staged_items.is_empty() { items.push( widget::settings::section() .title(fl!("staged-changes")) .add(widget::column::with_children(staged_items)) .into(), ); } } widget::column::with_children(items) .spacing(spacing.space_s) .padding([spacing.space_xxs, spacing.space_none]) .into() } else { widget::column::with_children(vec![ widget::text(fl!("git-management-description")).into(), widget::text(fl!("git-management-loading")).into(), ]) .spacing(spacing.space_s) .padding([spacing.space_xxs, spacing.space_none]) .into() } } fn project_search(&self) -> Element<'_, Message> { let spacing = self.core().system_theme().cosmic().spacing; let search_input = widget::text_input::search_input( fl!("project-search"), self.project_search_value.clone(), ) .id(self.project_search_id.clone()); let items = match &self.project_search_result { Some(project_search_result) => { let mut items = Vec::with_capacity(project_search_result.files.len().saturating_add(1)); if project_search_result.in_progress { items.push(search_input.into()); } else { items.push( search_input .on_input(Message::ProjectSearchValue) .on_submit(|_| Message::ProjectSearchSubmit) .into(), ); } for (file_i, file_search_result) in project_search_result.files.iter().enumerate() { let mut column = widget::column::with_capacity(file_search_result.lines.len()); let mut line_number_width = 1; if let Some(line_search_result) = file_search_result.lines.last() { let mut number = line_search_result.number; while number >= 10 { number /= 10; line_number_width += 1; } } for (line_i, line_search_result) in file_search_result.lines.iter().enumerate() { column = column.push( widget::button::custom( widget::row::with_children(vec![ widget::text(format!( "{:width$}", line_search_result.number, width = line_number_width, )) .font(Font::MONOSPACE) .into(), widget::text(line_search_result.text.to_string()) .font(Font::MONOSPACE) .into(), ]) .spacing(spacing.space_xs), ) .on_press(Message::OpenSearchResult(file_i, line_i)) .width(Length::Fill) .class(theme::Button::AppletMenu), ); } items.push( widget::settings::section() .title(format!("{}", file_search_result.path.display(),)) .add(column) .into(), ); } items } None => { vec![ search_input .on_input(Message::ProjectSearchValue) .on_submit(|_| Message::ProjectSearchSubmit) .into(), ] } }; widget::column::with_children(items) .spacing(spacing.space_s) .padding([spacing.space_xxs, spacing.space_none]) .into() } fn settings(&self) -> Element<'_, Message> { let app_theme_selected = match self.config.app_theme { AppTheme::Dark => 1, AppTheme::Light => 2, AppTheme::System => 0, }; let dark_selected = self .theme_names .iter() .position(|theme_name| theme_name == &self.config.syntax_theme_dark); let light_selected = self .theme_names .iter() .position(|theme_name| theme_name == &self.config.syntax_theme_light); let font_selected = { let mut font_system = font_system().write().unwrap(); let current_font_name = font_system.raw().db().family_name(&Family::Monospace); self.font_names .iter() .position(|font_name| font_name == current_font_name) }; let font_size_selected = self .font_sizes .iter() .position(|font_size| font_size == &self.config.font_size); let zoom_step_selected = self .zoom_steps .iter() .position(|zoom_step| zoom_step == &self.config.font_size_zoom_step_mul_100); widget::settings::view_column(vec![ widget::settings::section() .title(fl!("appearance")) .add( widget::settings::item::builder(fl!("theme")).control(widget::dropdown( &self.app_themes, Some(app_theme_selected), move |index| { Message::AppTheme(match index { 1 => AppTheme::Dark, 2 => AppTheme::Light, _ => AppTheme::System, }) }, )), ) .add( widget::settings::item::builder(fl!("syntax-dark")).control(widget::dropdown( &self.theme_names, dark_selected, move |index| Message::SyntaxTheme(index, true), )), ) .add( widget::settings::item::builder(fl!("syntax-light")).control(widget::dropdown( &self.theme_names, light_selected, move |index| Message::SyntaxTheme(index, false), )), ) .add( widget::settings::item::builder(fl!("default-font")).control(widget::dropdown( &self.font_names, font_selected, Message::DefaultFont, )), ) .add( widget::settings::item::builder(fl!("default-font-size")).control( widget::dropdown(&self.font_size_names, font_size_selected, |index| { Message::DefaultFontSize(index) }), ), ) .add( widget::settings::item::builder(fl!("default-zoom-step")).control( widget::dropdown(&self.zoom_step_names, zoom_step_selected, |index| { Message::DefaultZoomStep(index) }), ), ) .into(), widget::settings::section() .title(fl!("keyboard-shortcuts")) .add( widget::settings::item::builder(fl!("enable-vim-bindings")) .toggler(self.config.vim_bindings, Message::VimBindings), ) .into(), ]) .into() } } /// Implement [`cosmic::Application`] to integrate with COSMIC. impl Application for App { /// Default async executor to use with the app. type Executor = executor::Default; /// Argument received [`cosmic::Application::new`]. type Flags = Flags; /// Message type specific to our [`App`]. type Message = Message; /// The unique application ID to supply to the window manager. const APP_ID: &'static str = "com.system76.CosmicEdit"; fn core(&self) -> &Core { &self.core } fn core_mut(&mut self) -> &mut Core { &mut self.core } /// Creates the application, and optionally emits command on initialize. fn init(core: Core, flags: Self::Flags) -> (Self, Task) { // Update font name from config { let mut font_system = font_system().write().unwrap(); font_system .raw() .db_mut() .set_monospace_family(&flags.config.font_name); } let app_themes = vec![fl!("match-desktop"), fl!("dark"), fl!("light")]; let font_names = { let mut font_names = Vec::new(); let mut font_system = font_system().write().unwrap(); let attrs = monospace_attrs(); for face in font_system.raw().db().faces() { if attrs.matches(face) && face.monospaced { //TODO: get localized name if possible let font_name = face .families .first() .map_or_else(|| face.post_script_name.to_string(), |x| x.0.to_string()); if !font_names.contains(&font_name) { font_names.push(font_name); } } } font_names.sort(); font_names }; let mut font_size_names = Vec::new(); let mut font_sizes = Vec::new(); for font_size in 4..=32 { font_size_names.push(format!("{}px", font_size)); font_sizes.push(font_size); } let mut theme_names = Vec::with_capacity(SYNTAX_SYSTEM.get().unwrap().theme_set.themes.len()); for (theme_name, _theme) in SYNTAX_SYSTEM.get().unwrap().theme_set.themes.iter() { theme_names.push(theme_name.to_string()); } let mut zoom_step_names = Vec::new(); let mut zoom_steps = Vec::new(); for zoom_step in [25, 50, 75, 100, 150, 200] { zoom_step_names.push(format!("{}px", f32::from(zoom_step) / 100.0)); zoom_steps.push(zoom_step); } let about = About::default() .name(fl!("cosmic-text-editor")) .icon(icon::from_name(Self::APP_ID)) .version(env!("CARGO_PKG_VERSION")) .author("System76") .license("GPL-3.0-only") .developers([("Jeremy Soller", "jeremy@system76.com")]) .links([ (fl!("repository"), "https://github.com/pop-os/cosmic-edit"), ( fl!("support"), "https://github.com/pop-os/cosmic-edit/issues", ), ]); let mut app = App { core, about, nav_model: nav_bar::Model::builder().build(), tab_model: segmented_button::Model::builder().build(), config_handler: flags.config_handler, config: flags.config, config_state_handler: flags.config_state_handler, config_state: flags.config_state, key_binds: key_binds(), zoom_step_names, zoom_steps, app_themes, font_names, font_size_names, font_sizes, theme_names, context_page: ContextPage::Settings, text_box_id: widget::Id::unique(), auto_scroll: None, dialog_opt: None, dialog_page_opt: None, find_opt: None, find_replace_id: widget::Id::unique(), find_replace_value: String::new(), find_search_id: widget::Id::unique(), find_search_value: String::new(), git_project_status: None, projects: Vec::new(), project_search_id: widget::Id::unique(), project_search_value: String::new(), project_search_result: None, watcher_opt: None, modifiers: Modifiers::empty(), }; // Do not show nav bar by default. Will be opened by open_project if needed app.core.nav_bar_set_toggled(false); for arg in env::args().skip(1) { let path = PathBuf::from(arg); if path.is_dir() { app.open_project(path); } else { app.open_tab(Some(path)); } } // Add button to open a project if none provided if app.nav_model.iter().next().is_none() { app.nav_model .insert() .icon(icon_cache_get("folder-open-symbolic", 16)) .text(fl!("open-project")); } // Open an empty file if no arguments provided if app.tab_model.iter().next().is_none() { app.open_tab(None); } //TODO: try update_config here? It breaks loading system theme by default let command = app.update_tab(); (app, command) } // The default nav_bar widget needs to be condensed for cosmic-edit fn nav_bar(&self) -> Option>> { if !self.core().nav_bar_active() { return None; } let nav_model = self.nav_model()?; let cosmic_theme::Spacing { space_none, space_s, space_xxxs, .. } = self.core().system_theme().cosmic().spacing; let mut nav = segmented_button::vertical(nav_model) .button_height(space_xxxs + 20 /* line height */ + space_xxxs) .button_padding([space_s, space_xxxs, space_s, space_xxxs]) .button_spacing(space_xxxs) .on_activate(|entity| action::cosmic(cosmic::app::Action::NavBar(entity))) .spacing(space_none) .style(theme::SegmentedButton::FileNav) .apply(widget::container) .padding(space_s) .width(Length::Shrink); if !self.core().is_condensed() { nav = nav.max_width(280); } Some( nav.apply(widget::scrollable) .apply(widget::container) .height(Length::Fill) .class(theme::Container::custom(nav_bar::nav_bar_style)) .into(), ) } fn nav_model(&self) -> Option<&nav_bar::Model> { Some(&self.nav_model) } fn on_app_exit(&mut self) -> Option { Some(Message::Quit) } fn on_context_drawer(&mut self) -> Task { // Focus correct widget self.update_focus() } //TODO: currently the first escape unfocuses, and the second calls this function fn on_escape(&mut self) -> Task { if self.core.window.show_context { // Close context drawer if open self.core.window.show_context = false; } else if self.find_opt.is_some() { // Close find if open self.find_opt = None; } // Focus correct widget self.update_focus() } fn on_nav_select(&mut self, id: nav_bar::Id) -> Task { // Toggle open state and get clone of node data let node_opt = match self.nav_model.data_mut::(id) { Some(node) => { if let ProjectNode::Folder { open, .. } = node { *open = !*open; } Some(node.clone()) } None => None, }; match node_opt { Some(node) => { // Update icon self.nav_model.icon_set(id, node.icon(16)); match node { ProjectNode::Folder { path, open, .. } => { let position = self.nav_model.position(id).unwrap_or(0); let indent = self.nav_model.indent(id).unwrap_or(0); if open { // Open folder self.open_folder(path, position + 1, indent + 1); } else { // Close folder while let Some(child_id) = self.nav_model.entity_at(position + 1) { if self.nav_model.indent(child_id).unwrap_or(0) > indent { self.nav_model.remove(child_id); } else { break; } } } // Prevent nav bar from closing when selecting a // folder in condensed mode. self.core_mut().nav_bar_set_toggled(true); Task::none() } ProjectNode::File { path, .. } => { //TODO: go to already open file if possible self.update(Message::OpenFile(path)) } } } None => { // Open project self.update(Message::OpenProjectDialog) } } } fn dialog(&self) -> Option> { let Some(ref dialog) = self.dialog_page_opt else { return None; }; let cosmic_theme::Spacing { space_xxs, .. } = self.core().system_theme().cosmic().spacing; match dialog { DialogPage::PromptSaveClose(entity) => { let save_button = widget::button::suggested(fl!("save")).on_press(Message::Save(Some(*entity))); let discard_button = widget::button::destructive(fl!("discard")) .on_press(Message::TabCloseForce(*entity)); let cancel_button = widget::button::text(fl!("cancel")).on_press(Message::DialogCancel); let dialog = widget::dialog() .title(fl!("prompt-save-changes-title")) .body(fl!("prompt-unsaved-changes")) .icon(icon::from_name("dialog-warning-symbolic").size(64)) .primary_action(save_button) .secondary_action(discard_button) .tertiary_action(cancel_button); Some(dialog.into()) } DialogPage::PromptSaveQuit(entities) => { let mut can_save_all = true; let mut column = widget::column::with_capacity(entities.len()).spacing(space_xxs); for entity in entities.iter() { if let Some(Tab::Editor(tab)) = self.tab_model.data::(*entity) { let mut row = widget::row::with_capacity(3).align_y(Alignment::Center); row = row.push(widget::text(tab.title())); row = row.push(widget::horizontal_space()); if let Some(_path) = &tab.path_opt { row = row.push( widget::button::standard(fl!("save")) .on_press(Message::Save(Some(*entity))), ); //TODO row = row.push(widget::text(format!("{}", path.display()))); } else { row = row.push( widget::button::standard(fl!("save-as")) .on_press(Message::SaveAsDialog(Some(*entity))), ); can_save_all = false; } column = column.push(row); } } let mut save_button = widget::button::suggested(fl!("save-all")); if can_save_all { save_button = save_button.on_press(Message::SaveAll); } let discard_button = widget::button::destructive(fl!("discard")).on_press(Message::QuitForce); let cancel_button = widget::button::text(fl!("cancel")).on_press(Message::DialogCancel); let dialog = widget::dialog() .title(fl!("prompt-save-changes-title")) .body(fl!("prompt-unsaved-changes")) .icon(icon::from_name("dialog-warning-symbolic").size(64)) .control(column) .primary_action(save_button) .secondary_action(discard_button) .tertiary_action(cancel_button); Some(dialog.into()) } } } fn update(&mut self, message: Message) -> Task { // Helper for updating config values efficiently macro_rules! config_set { ($name: ident, $value: expr) => { match &self.config_handler { Some(config_handler) => { if let Err(err) = paste::paste! { self.config.[](config_handler, $value) } { log::warn!("failed to save config {:?}: {}", stringify!($name), err); } } None => { self.config.$name = $value; log::warn!( "failed to save config {:?}: no config handler", stringify!($name) ); } } }; } match message { Message::AppTheme(app_theme) => { config_set!(app_theme, app_theme); return self.update_config(); } Message::AutoScroll(auto_scroll) => { self.auto_scroll = auto_scroll; } Message::Config(config) => { if config != self.config { log::info!("update config"); //TODO: update syntax theme by clearing tabs, only if needed self.config = config; return self.update_config(); } } Message::ConfigState(config_state) => { if config_state != self.config_state { log::info!("update config state"); self.config_state = config_state; } } Message::CloseFile => { return self.update(Message::TabClose(self.tab_model.active())); } Message::CloseProject(project_i) => { if project_i < self.projects.len() { let (_project_name, project_path) = self.projects.remove(project_i); let mut position = 0; let mut closing = false; while let Some(id) = self.nav_model.entity_at(position) { match self.nav_model.data::(id) { Some(node) => { if let ProjectNode::Folder { path, root, .. } = node { if path == &project_path { // Found the project root node, closing closing = true; } else if *root && closing { // Found another project root node after closing, breaking break; } } } None => { if closing { break; } } } if closing { self.nav_model.remove(id); } else { position += 1; } } } } Message::CloseWindow(window_id) => { if Some(window_id) == self.core.main_window_id() { return self.update(Message::Quit); } } Message::Copy => { if let Some(Tab::Editor(tab)) = self.active_tab() { let editor = tab.editor.lock().unwrap(); let selection_opt = editor.copy_selection(); if let Some(selection) = selection_opt { return clipboard::write(selection); } } } Message::Cut => { if let Some(Tab::Editor(tab)) = self.active_tab() { let selection_opt = { let mut editor = tab.editor.lock().unwrap(); let selection_opt = editor.copy_selection(); editor.start_change(); editor.delete_selection(); editor.finish_change(); selection_opt }; if let Some(selection) = selection_opt { return Task::batch([ clipboard::write(selection), self.update(Message::TabChanged(self.tab_model.active())), ]); } } } Message::DefaultFont(index) => { match self.font_names.get(index) { Some(font_name) => { if font_name != &self.config.font_name { // Update font name from config { let mut font_system = font_system().write().unwrap(); font_system.raw().db_mut().set_monospace_family(font_name); } // Reset line number cache { let mut line_number_cache = LINE_NUMBER_CACHE.get().unwrap().lock().unwrap(); line_number_cache.clear(); } // This does a complete reset of shaping data! let entities: Vec<_> = self.tab_model.iter().collect(); for entity in entities { if let Some(Tab::Editor(tab)) = self.tab_model.data_mut::(entity) { let mut editor = tab.editor.lock().unwrap(); editor.with_buffer_mut(|buffer| { for line in buffer.lines.iter_mut() { line.reset(); } }); } } config_set!(font_name, font_name.to_string()); return self.update_config(); } } None => { log::warn!("failed to find font with index {}", index); } } } Message::DefaultFontSize(index) => match self.font_sizes.get(index) { Some(font_size) => { config_set!(font_size, *font_size); self.reset_tabs_zoom(); return self.update_config(); } None => { log::warn!("failed to find font with index {}", index); } }, Message::ZoomIn => { return self.update_render_active_tab_zoom(message); } Message::ZoomOut => { return self.update_render_active_tab_zoom(message); } Message::ZoomReset => { self.reset_tabs_zoom(); return self.update_config(); } Message::DefaultZoomStep(index) => match self.zoom_steps.get(index) { Some(zoom_step) => { config_set!(font_size_zoom_step_mul_100, *zoom_step); self.reset_tabs_zoom(); // reset zoom return self.update_config(); } None => { log::warn!("failed to find zoom step with index {}", index); } }, Message::DialogCancel => { self.dialog_page_opt = None; } Message::DialogMessage(dialog_message) => { if let Some(dialog) = &mut self.dialog_opt { return dialog.update(dialog_message); } } Message::Find(find_opt) => { self.find_opt = find_opt; // Focus correct input return self.update_focus(); } Message::FindCaseSensitive(find_case_sensitive) => { config_set!(find_case_sensitive, find_case_sensitive); return self.update_config(); } Message::FindNext => { if !self.find_search_value.is_empty() { if let Some(Tab::Editor(tab)) = self.active_tab() { //TODO: do not compile find regex on every search? match self.config.find_regex(&self.find_search_value) { Ok(regex) => { tab.search(®ex, true, self.config.find_wrap_around); } Err(err) => { //TODO: put regex error in find box log::warn!( "failed to compile regex {:?}: {}", self.find_search_value, err ); } } } } // Focus correct input return self.update_focus(); } Message::FindPrevious => { if !self.find_search_value.is_empty() { if let Some(Tab::Editor(tab)) = self.active_tab() { //TODO: do not compile find regex on every search? match self.config.find_regex(&self.find_search_value) { Ok(regex) => { tab.search(®ex, false, self.config.find_wrap_around); } Err(err) => { //TODO: put regex error in find box log::warn!( "failed to compile regex {:?}: {}", self.find_search_value, err ); } } } } // Focus correct input return self.update_focus(); } Message::FindReplace => { if !self.find_search_value.is_empty() { if let Some(Tab::Editor(tab)) = self.active_tab() { //TODO: do not compile find regex on every search? match self.config.find_regex(&self.find_search_value) { Ok(regex) => { //TODO: support captures tab.replace( ®ex, &self.find_replace_value, self.config.find_wrap_around, ); return self.update(Message::TabChanged(self.tab_model.active())); } Err(err) => { //TODO: put regex error in find box log::warn!( "failed to compile regex {:?}: {}", self.find_search_value, err ); } } } } // Focus correct input return self.update_focus(); } Message::FindReplaceAll => { if !self.find_search_value.is_empty() { if let Some(Tab::Editor(tab)) = self.active_tab() { //TODO: do not compile find regex on every search? match self.config.find_regex(&self.find_search_value) { Ok(regex) => { //TODO: support captures { let mut editor = tab.editor.lock().unwrap(); editor.set_cursor(cosmic_text::Cursor::new(0, 0)); } while tab.replace(®ex, &self.find_replace_value, false) {} return self.update(Message::TabChanged(self.tab_model.active())); } Err(err) => { //TODO: put regex error in find box log::warn!( "failed to compile regex {:?}: {}", self.find_search_value, err ); } } } } // Focus correct input return self.update_focus(); } Message::FindReplaceValueChanged(value) => { self.find_replace_value = value; } Message::FindSearchValueChanged(value) => { self.find_search_value = value; } Message::FindUseRegex(find_use_regex) => { config_set!(find_use_regex, find_use_regex); return self.update_config(); } Message::FindWrapAround(find_wrap_around) => { config_set!(find_wrap_around, find_wrap_around); return self.update_config(); } Message::GitProjectStatus(project_status) => { self.git_project_status = Some(project_status); } Message::GitStage(project_path, path) => { return Task::perform( async move { //TODO: send errors to UI match GitRepository::new(&project_path) { Ok(repo) => match repo.stage(&path).await { Ok(()) => { return action::app(Message::UpdateGitProjectStatus); } Err(err) => { log::error!( "failed to stage {:?} in {:?}: {}", path, project_path, err ); } }, Err(err) => { log::error!( "failed to open repository {:?}: {}", project_path, err ); } } action::none() }, |x| x, ); } Message::GitUnstage(project_path, path) => { return Task::perform( async move { //TODO: send errors to UI match GitRepository::new(&project_path) { Ok(repo) => match repo.unstage(&path).await { Ok(()) => { return action::app(Message::UpdateGitProjectStatus); } Err(err) => { log::error!( "failed to unstage {:?} in {:?}: {}", path, project_path, err ); } }, Err(err) => { log::error!( "failed to open repository {:?}: {}", project_path, err ); } } action::none() }, |x| x, ); } Message::Key(modifiers, key) => { for (key_bind, action) in self.key_binds.iter() { if key_bind.matches(modifiers, &key) { return self.update(action.message(None)); } } } Message::LaunchUrl(url) => match open::that_detached(&url) { Ok(()) => {} Err(err) => { log::warn!("failed to open {:?}: {}", url, err); } }, Message::Modifiers(modifiers) => { self.modifiers = modifiers; } Message::NewFile => { self.open_tab(None); return self.update_tab(); } Message::NewWindow => { //TODO: support multi-window in winit match env::current_exe() { Ok(exe) => match process::Command::new(&exe).spawn() { Ok(_child) => {} Err(err) => { log::error!("failed to execute {:?}: {}", exe, err); } }, Err(err) => { log::error!("failed to get current executable path: {}", err); } } } Message::NotifyEvent(event) => { let mut needs_reload = Vec::new(); for entity in self.tab_model.iter() { if let Some(Tab::Editor(tab)) = self.tab_model.data::(entity) { if let Some(path) = &tab.path_opt { if event.paths.contains(path) { if tab.changed() { log::warn!( "file changed externally before being saved: {:?}", path ); } else { needs_reload.push(entity); } } } } } for entity in needs_reload { match self.tab_model.data_mut::(entity) { Some(Tab::Editor(tab)) => { tab.reload(); } _ => { log::warn!("failed to find tab {:?} that needs reload", entity); } } } } Message::NotifyWatcher(mut watcher_wrapper) => match watcher_wrapper.watcher_opt.take() { Some(watcher) => { self.watcher_opt = Some(watcher); for entity in self.tab_model.iter() { if let Some(Tab::Editor(tab)) = self.tab_model.data::(entity) { tab.watch(&mut self.watcher_opt); } } } None => { log::warn!("message did not contain notify watcher"); } }, Message::OpenFile(path) => { self.open_tab(Some(path)); return self.update_tab(); } Message::OpenFileDialog => { if self.dialog_opt.is_none() { let (dialog, command) = Dialog::new( DialogSettings::new().kind(DialogKind::OpenMultipleFiles), Message::DialogMessage, Message::OpenFileResult, ); self.dialog_opt = Some(dialog); return command; } } Message::OpenFileResult(result) => { self.dialog_opt = None; match result { DialogResult::Cancel => {} DialogResult::Open(paths) => { for path in paths { match self.active_tab_mut() { // Replace the current tab if it was never saved nor is currently modified // * A tab with a loaded file is not replaced // * Empty or new tabs are replaced // * Tabs that are "undone" to being empty and NOT associated with // a file are replaced Some(Tab::Editor(tab)) if tab.path_opt.is_none() && !tab.editor.lock().unwrap().changed() => { self.replace_tab(path, self.tab_model.active()); } _ => { self.open_tab(Some(path)); } } } return self.update_tab(); } } } Message::OpenGitDiff(project_path, diff) => { let relative_path = match diff.path.strip_prefix(project_path.clone()) { Ok(ok) => ok, Err(err) => { log::warn!( "failed to find relative path of {:?} in project {:?}: {}", diff.path, project_path, err ); &diff.path } }; let title = format!( "{}: {}", if diff.staged { fl!("staged-changes") } else { fl!("unstaged-changes") }, relative_path.display() ); let icon = icon::icon(mime_icon(mime_for_path(&diff.path, None, false), 16)).size(16); let tab = Tab::GitDiff(GitDiffTab { title, diff }); self.tab_model .insert() .text(tab.title()) .icon(icon) .data::(tab) .closable() .activate(); return self.update_tab(); } Message::OpenProjectDialog => { if self.dialog_opt.is_none() { let (dialog, command) = Dialog::new( DialogSettings::new().kind(DialogKind::OpenMultipleFolders), Message::DialogMessage, Message::OpenProjectResult, ); self.dialog_opt = Some(dialog); return command; } } Message::OpenProjectResult(result) => { self.dialog_opt = None; match result { DialogResult::Cancel => {} DialogResult::Open(paths) => { for path in paths { self.open_project(path); } } } } Message::OpenRecentFile(index) => { if let Some(path) = self.config_state.recent_files.get(index).cloned() { self.open_tab(Some(path)); return self.update_tab(); } } Message::OpenRecentProject(index) => { if let Some(path) = self.config_state.recent_projects.get(index).cloned() { self.open_project(path); } } Message::OpenSearchResult(file_i, line_i) => { let path_cursor_opt = match &self.project_search_result { Some(project_search_result) => match project_search_result.files.get(file_i) { Some(file_search_result) => match file_search_result.lines.get(line_i) { Some(line_search_result) => Some(( file_search_result.path.to_path_buf(), Cursor::new( line_search_result.number.saturating_sub(1), line_search_result.first.start(), ), )), None => { log::warn!("failed to find search result {}, {}", file_i, line_i); None } }, None => { log::warn!("failed to find search result {}", file_i); None } }, None => None, }; if let Some((path, cursor)) = path_cursor_opt { if let Some(entity) = self.open_tab(Some(path)) { return Task::batch([ //TODO: why must this be done in a command? Task::perform( async move { action::app(Message::TabSetCursor(entity, cursor)) }, |x| x, ), self.update_tab(), ]); } } } Message::Paste => { return clipboard::read().map(|value_opt| match value_opt { Some(value) => action::app(Message::PasteValue(value)), None => action::none(), }); } Message::PasteValue(value) => { if let Some(Tab::Editor(tab)) = self.active_tab() { { let mut editor = tab.editor.lock().unwrap(); editor.start_change(); editor.insert_string(&value, None); editor.finish_change(); } return self.update(Message::TabChanged(self.tab_model.active())); } } Message::PrepareGitDiff(project_path, path, staged) => { return Task::perform( async move { //TODO: send errors to UI match GitRepository::new(&project_path) { Ok(repo) => match repo.diff(&path, staged).await { Ok(diff) => { return action::app(Message::OpenGitDiff(project_path, diff)); } Err(err) => { log::error!( "failed to get diff of {:?} in {:?}: {}", path, project_path, err ); } }, Err(err) => { log::error!( "failed to open repository {:?}: {}", project_path, err ); } } action::none() }, |x| x, ); } Message::ProjectSearchResult(project_search_result) => { self.project_search_result = Some(project_search_result); // Focus correct input return self.update_focus(); } Message::ProjectSearchSubmit => { //TODO: Figure out length requirements? if !self.project_search_value.is_empty() { let projects = self.projects.clone(); let project_search_value = self.project_search_value.clone(); let mut project_search_result = ProjectSearchResult { value: project_search_value.clone(), in_progress: true, files: Vec::new(), }; self.project_search_result = Some(project_search_result.clone()); return Task::perform( async move { let task_res = tokio::task::spawn_blocking(move || { project_search_result.search_projects(projects); action::app(Message::ProjectSearchResult(project_search_result)) }) .await; match task_res { Ok(message) => message, Err(err) => { log::error!("failed to run search task: {}", err); action::none() } } }, |x| x, ); } } Message::ProjectSearchValue(value) => { self.project_search_value = value; } Message::PromptSaveChanges(entity) => { self.dialog_page_opt = Some(DialogPage::PromptSaveClose(entity)); } Message::Quit => { // Create empty dialog self.dialog_page_opt = Some(DialogPage::PromptSaveQuit(Vec::new())); // This update will get the actual list of unsaved tabs return self.update_dialogs(); } Message::QuitForce => { process::exit(0); } Message::Redo => { if let Some(Tab::Editor(tab)) = self.active_tab() { { let mut editor = tab.editor.lock().unwrap(); editor.redo(); } return self.update(Message::TabChanged(self.tab_model.active())); } } Message::RevertAllChanges => { if let Some(Tab::Editor(tab)) = self.active_tab_mut() { tab.reload(); return self.update(Message::TabChanged(self.tab_model.active())); } } Message::Save(entity_opt) => { let mut title_opt = None; let entity = entity_opt.unwrap_or_else(|| self.tab_model.active()); if let Some(Tab::Editor(tab)) = self.tab_model.data_mut::(entity) { if tab.path_opt.is_none() { return self.update(Message::SaveAsDialog(Some(entity))); } title_opt = Some(tab.title()); tab.save(); } if let Some(title) = title_opt { self.tab_model.text_set(self.tab_model.active(), title); } return self.update_dialogs(); } Message::SaveAll => { let entities: Vec<_> = self.tab_model.iter().collect(); for entity in entities { if let Some(Tab::Editor(tab)) = self.tab_model.data_mut::(entity) { if tab.path_opt.is_none() { log::warn!("{} has no path when doing save all", tab.title()); } tab.save(); } } return self.update_dialogs(); } Message::SaveAsDialog(entity_opt) => { if self.dialog_opt.is_none() { let entity = entity_opt.unwrap_or_else(|| self.tab_model.active()); if let Some(Tab::Editor(tab)) = self.tab_model.data::(entity) { let (filename, path_opt) = match &tab.path_opt { Some(path) => ( path.file_name() .and_then(|x| x.to_str()) .map(|x| x.to_string()) .unwrap_or(String::new()), path.parent().map(|x| x.to_path_buf()), ), None => (String::new(), None), }; let mut settings = DialogSettings::new().kind(DialogKind::SaveFile { filename }); if let Some(path) = path_opt { settings = settings.path(path); } let (dialog, command) = Dialog::new(settings, Message::DialogMessage, move |result| { Message::SaveAsResult(entity, result) }); self.dialog_opt = Some(dialog); return command; } } } Message::SaveAsResult(entity, result) => { self.dialog_opt = None; match result { DialogResult::Cancel => {} DialogResult::Open(mut paths) => { if !paths.is_empty() { let mut title_opt = None; if let Some(Tab::Editor(tab)) = self.tab_model.data_mut::(entity) { tab.path_opt = Some(paths.remove(0)); title_opt = Some(tab.title()); tab.save(); } if let Some(title) = title_opt { self.tab_model.text_set(entity, title); } return self.update_dialogs(); } } } } Message::SelectAll => { if let Some(Tab::Editor(tab)) = self.active_tab_mut() { let mut editor = tab.editor.lock().unwrap(); // Set cursor to lowest possible value editor.set_cursor(Cursor::new(0, 0)); // Set selection end to highest possible value let selection = editor.with_buffer(|buffer| { let last_line = buffer.lines.len().saturating_sub(1); let last_index = buffer.lines[last_line].text().len(); Selection::Normal(Cursor::new(last_line, last_index)) }); editor.set_selection(selection); } } Message::Scroll(auto_scroll) => { if let Some(Tab::Editor(tab)) = self.active_tab_mut() { let mut editor = tab.editor.lock().unwrap(); editor.with_buffer_mut(|buffer| { let mut scroll = buffer.scroll(); scroll.vertical += auto_scroll; buffer.set_scroll(scroll); }); } } Message::Surface(a) => { return cosmic::task::message(cosmic::Action::Cosmic( cosmic::app::Action::Surface(a), )); } Message::SystemThemeModeChange(_theme_mode) => { return self.update_config(); } Message::SyntaxTheme(index, dark) => match self.theme_names.get(index) { Some(theme_name) => { if dark { config_set!(syntax_theme_dark, theme_name.to_string()); } else { config_set!(syntax_theme_light, theme_name.to_string()); } return self.update_config(); } None => { log::warn!("failed to find syntax theme with index {}", index); } }, Message::TabActivate(entity) => { // Close save changes dialog if switching to a different tab for consistency if self.dialog_page_opt != Some(DialogPage::PromptSaveClose(entity)) { self.dialog_page_opt = None; } self.tab_model.activate(entity); return self.update_tab(); } Message::TabActivateJump(pos) => { // Length is always at least one, so there shouldn't be a division by zero let len = self.tab_model.iter().count(); // Indices 1 to 8 jumps to tabs 1-8 while 9 jumps to the last let pos = if pos >= 8 || pos > len - 1 { len - 1 } else { pos % len }; let entity = self.tab_model.iter().nth(pos); if let Some(entity) = entity { return self.update(Message::TabActivate(entity)); } } Message::TabChanged(entity) => { if let Some(Tab::Editor(tab)) = self.tab_model.data::(entity) { let mut title = tab.title(); //TODO: better way of adding change indicator if tab.changed() { title.push_str(" \u{2022}"); } self.tab_model.text_set(entity, title); } } Message::TabClose(entity) => { match self.tab_model.data_mut::(entity) { // Only match a changed editor tab... Some(Tab::Editor(tab)) if tab.changed() => { // The save prompt shouldn't be closed if `TabClose` is emitted again for // the same tab. // // `PromptSaveClose` for a different tab other than `entity` counts as // a different dialog // Ex. If tab 2 and 3 both have unsaved changes and `PromptSaveClose` is // emitted for tab 2, closing tab 3 should open the dialog for tab 3 in // order for `Message::Save` to save the correct tab. return Task::batch([ // Focus the tab in case the user is closing an unfocussed tab // Otherwise, closing an unfocussed tab would be very confusing self.update(Message::TabActivate(entity)), self.update(Message::PromptSaveChanges(entity)), ]); } // ...or else just close it _ => { return self.update(Message::TabCloseForce(entity)); } } } Message::TabCloseForce(entity) => { // Activate closest item if let Some(position) = self.tab_model.position(entity) { if position > 0 { self.tab_model.activate_position(position - 1); } else { self.tab_model.activate_position(position + 1); } } // Remove item self.tab_model.remove(entity); // If that was the last tab, make a new empty one if self.tab_model.iter().next().is_none() { self.open_tab(None); } // Close PromptSaveClose dialog if open for this entity if self.dialog_page_opt == Some(DialogPage::PromptSaveClose(entity)) { self.dialog_page_opt = None; } return self.update_tab(); } Message::TabContextAction(entity, action) => { if let Some(Tab::Editor(tab)) = self.tab_model.data_mut::(entity) { // Close context menu tab.context_menu = None; // Run action's message return self.update(action.message(None)); } } Message::TabContextMenu(entity, position_opt) => { if let Some(Tab::Editor(tab)) = self.tab_model.data_mut::(entity) { // Update context menu tab.context_menu = position_opt; } } Message::TabNext => { let len = self.tab_model.iter().count(); // Next tab position. Wraps around to 0 (the first tab) if the last tab is active. let pos = self .tab_model .position(self.tab_model.active()) .map(|i| (i as usize + 1) % len) .expect("at least one tab is always open"); let entity = self.tab_model.iter().nth(pos); if let Some(entity) = entity { return self.update(Message::TabActivate(entity)); } } Message::TabPrev => { let pos = self .tab_model .position(self.tab_model.active()) .and_then(|i| (i as usize).checked_sub(1)) .unwrap_or_else(|| { self.tab_model .iter() .count() .checked_sub(1) .unwrap_or_default() }); let entity = self.tab_model.iter().nth(pos); if let Some(entity) = entity { return self.update(Message::TabActivate(entity)); } } Message::TabSetCursor(entity, cursor) => { if let Some(Tab::Editor(tab)) = self.tab_model.data::(entity) { let mut editor = tab.editor.lock().unwrap(); editor.set_cursor(cursor); } } Message::TabWidth(tab_width) => { config_set!(tab_width, tab_width); return self.update_config(); } Message::Todo => { log::warn!("TODO"); } Message::ToggleAutoIndent => { config_set!(auto_indent, !self.config.auto_indent); return self.update_config(); } Message::ToggleContextPage(context_page) => { if self.context_page == context_page { self.core.window.show_context = !self.core.window.show_context; } else { self.context_page = context_page; self.core.window.show_context = true; } // Execute commands for specific pages if self.core.window.show_context && self.context_page == ContextPage::GitManagement { return self.update(Message::UpdateGitProjectStatus); } // Ensure focus of correct input return self.update_focus(); } Message::ToggleHighlightCurrentLine => { config_set!(highlight_current_line, !self.config.highlight_current_line); // This forces a redraw of all buffers let entities: Vec<_> = self.tab_model.iter().collect(); for entity in entities { if let Some(Tab::Editor(tab)) = self.tab_model.data_mut::(entity) { let mut editor = tab.editor.lock().unwrap(); editor.set_redraw(true); } } return self.update_config(); } Message::ToggleLineNumbers => { config_set!(line_numbers, !self.config.line_numbers); // This forces a redraw of all buffers let entities: Vec<_> = self.tab_model.iter().collect(); for entity in entities { if let Some(Tab::Editor(tab)) = self.tab_model.data_mut::(entity) { let mut editor = tab.editor.lock().unwrap(); editor.set_redraw(true); } } return self.update_config(); } Message::ToggleWordWrap => { config_set!(word_wrap, !self.config.word_wrap); return self.update_config(); } Message::Undo => { if let Some(Tab::Editor(tab)) = self.active_tab() { { let mut editor = tab.editor.lock().unwrap(); editor.undo(); } return self.update(Message::TabChanged(self.tab_model.active())); } } Message::UpdateGitProjectStatus => { self.git_project_status = None; let projects = self.projects.clone(); return Task::perform( async move { let mut project_status = Vec::new(); for (project_name, project_path) in projects.iter() { //TODO: send errors to UI match GitRepository::new(project_path) { Ok(repo) => match repo.status().await { Ok(status) => { if !status.is_empty() { project_status.push(( project_name.clone(), project_path.clone(), status, )); } } Err(err) => { log::error!( "failed to get status of {:?}: {}", project_path, err ); } }, Err(err) => { log::error!( "failed to open repository {:?}: {}", project_path, err ); } } } action::app(Message::GitProjectStatus(project_status)) }, |x| x, ); } Message::VimBindings(vim_bindings) => { config_set!(vim_bindings, vim_bindings); return self.update_config(); } Message::Focus(window_id) => { if Some(window_id) == self.core.main_window_id() { // focus the text box if context page is not shown if !self.core.window.show_context { return self.update_focus(); } } } } Task::none() } fn context_drawer(&self) -> Option> { if !self.core.window.show_context { return None; } Some(match self.context_page { ContextPage::About => context_drawer::about( &self.about, |s| Message::LaunchUrl(s.to_string()), Message::ToggleContextPage(ContextPage::About), ), ContextPage::DocumentStatistics => context_drawer::context_drawer( self.document_statistics(), Message::ToggleContextPage(ContextPage::DocumentStatistics), ) .title(fl!("document-statistics")), ContextPage::GitManagement => context_drawer::context_drawer( self.git_management(), Message::ToggleContextPage(ContextPage::GitManagement), ) .title(fl!("git-management")), ContextPage::ProjectSearch => context_drawer::context_drawer( self.project_search(), Message::ToggleContextPage(ContextPage::ProjectSearch), ) .title(fl!("project-search")), ContextPage::Settings => context_drawer::context_drawer( self.settings(), Message::ToggleContextPage(ContextPage::Settings), ) .title(fl!("settings")), }) } fn header_start(&self) -> Vec> { vec![menu_bar( &self.core, &self.config, &self.config_state, &self.key_binds, &self.projects, )] } fn view(&self) -> Element<'_, Message> { let cosmic_theme::Spacing { space_none, space_xxs, .. } = self.core().system_theme().cosmic().spacing; let mut tab_column = widget::column::with_capacity(3).padding([space_none, space_xxs]); tab_column = tab_column.push( widget::row::with_capacity(2) .align_y(Alignment::Center) .push( widget::tab_bar::horizontal(&self.tab_model) .button_height(32) .button_spacing(space_xxs) .close_icon(icon_cache_get("window-close-symbolic", 16)) //TODO: this causes issues with small window sizes .minimum_button_width(240) .on_activate(Message::TabActivate) .on_close(Message::TabClose) .width(Length::Shrink), ) .push( button::custom(icon_cache_get("list-add-symbolic", 16)) .on_press(Message::NewFile) .padding(space_xxs) .class(style::Button::Icon), ), ); let tab_id = self.tab_model.active(); match self.tab_model.data::(tab_id) { Some(Tab::Editor(tab)) => { let mut text_box = text_box(&tab.editor, self.config.metrics(tab.zoom_adj())) .id(self.text_box_id.clone()) .on_auto_scroll(Message::AutoScroll) .on_changed(Message::TabChanged(tab_id)) .has_context_menu(tab.context_menu.is_some()) .on_context_menu(move |position_opt| { Message::TabContextMenu(tab_id, position_opt) }); if self.config.highlight_current_line { text_box = text_box.highlight_current_line(); } if self.config.line_numbers { text_box = text_box.line_numbers(); } let mut popover = widget::popover(text_box); if let Some(point) = tab.context_menu { popover = popover .popup(menu::context_menu(&self.key_binds, tab_id)) .position(widget::popover::Position::Point(point)); } tab_column = tab_column.push(popover); if self.config.vim_bindings { let status = { let editor = tab.editor.lock().unwrap(); let parser = editor.parser(); match &parser.mode { ViMode::Normal => { format!("{}", parser.cmd) } ViMode::Insert => "-- INSERT --".to_string(), ViMode::Extra(extra) => { format!("{}{}", parser.cmd, extra) } ViMode::Replace => "-- REPLACE --".to_string(), ViMode::Visual => { format!("-- VISUAL -- {}", parser.cmd) } ViMode::VisualLine => { format!("-- VISUAL LINE -- {}", parser.cmd) } ViMode::Command { value } => { format!(":{value}|") } ViMode::Search { value, forwards } => { if *forwards { format!("/{value}|") } else { format!("?{value}|") } } } }; tab_column = tab_column.push(widget::text(status).font(Font::MONOSPACE)); } } Some(Tab::GitDiff(tab)) => { let mut diff_widget = widget::column::with_capacity(tab.diff.hunks.len()); for hunk in tab.diff.hunks.iter() { let mut hunk_widget = widget::column::with_capacity(hunk.lines.len()); for line in hunk.lines.iter() { let line_widget = match line { GitDiffLine::Context { old_line, new_line, text, } => widget::container(widget::text::monotext(format!( "{:4} {:4} {}", old_line, new_line, text ))), GitDiffLine::Added { new_line, text } => { widget::container(widget::text::monotext(format!( "{:4} {:4} + {}", "", new_line, text ))) .style(|_theme| { //TODO: theme this color widget::container::Style { background: Some(Background::Color(Color::from_rgb8( 0x00, 0x40, 0x00, ))), ..Default::default() } }) } GitDiffLine::Deleted { old_line, text } => { widget::container(widget::text::monotext(format!( "{:4} {:4} - {}", old_line, "", text ))) .style(|_theme| { //TODO: theme this color widget::container::Style { background: Some(Background::Color(Color::from_rgb8( 0x40, 0x00, 0x00, ))), ..Default::default() } }) } }; hunk_widget = hunk_widget.push(line_widget.width(Length::Fill)); } diff_widget = diff_widget.push(hunk_widget); } tab_column = tab_column.push(widget::scrollable( widget::layer_container(diff_widget).layer(cosmic_theme::Layer::Primary), )); } None => {} } if let Some(replace) = &self.find_opt { let find_input = widget::text_input::text_input(fl!("find-placeholder"), &self.find_search_value) .id(self.find_search_id.clone()) .on_input(Message::FindSearchValueChanged) .on_submit(|_| { if self.modifiers.contains(Modifiers::SHIFT) { Message::FindPrevious } else { Message::FindNext } }) .width(Length::Fixed(320.0)) .trailing_icon( button::custom(icon_cache_get("edit-clear-symbolic", 16)) .on_press(Message::FindSearchValueChanged(String::new())) .class(style::Button::Icon) .into(), ); let find_widget = widget::row::with_children(vec![ find_input.into(), widget::tooltip( button::custom(icon_cache_get("go-up-symbolic", 16)) .on_press(Message::FindPrevious) .padding(space_xxs) .class(style::Button::Icon), widget::text::body(fl!("find-previous")), widget::tooltip::Position::Top, ) .into(), widget::tooltip( button::custom(icon_cache_get("go-down-symbolic", 16)) .on_press(Message::FindNext) .padding(space_xxs) .class(style::Button::Icon), widget::text::body(fl!("find-next")), widget::tooltip::Position::Top, ) .into(), widget::horizontal_space().into(), button::custom(icon_cache_get("window-close-symbolic", 16)) .on_press(Message::Find(None)) .padding(space_xxs) .class(style::Button::Icon) .into(), ]) .align_y(Alignment::Center) .padding(space_xxs) .spacing(space_xxs); let mut column = widget::column::with_capacity(3).push(find_widget); if *replace { let replace_input = widget::text_input::text_input( fl!("replace-placeholder"), &self.find_replace_value, ) .id(self.find_replace_id.clone()) .on_input(Message::FindReplaceValueChanged) .on_submit(|_| Message::FindReplace) .width(Length::Fixed(320.0)) .trailing_icon( button::custom(icon_cache_get("edit-clear-symbolic", 16)) .on_press(Message::FindReplaceValueChanged(String::new())) .class(style::Button::Icon) .into(), ); let replace_widget = widget::row::with_children(vec![ replace_input.into(), widget::tooltip( button::custom(icon_cache_get("replace-symbolic", 16)) .on_press(Message::FindReplace) .padding(space_xxs) .class(style::Button::Icon), widget::text::body(fl!("replace")), widget::tooltip::Position::Top, ) .into(), widget::tooltip( button::custom(icon_cache_get("replace-all-symbolic", 16)) .on_press(Message::FindReplaceAll) .padding(space_xxs) .class(style::Button::Icon), widget::text::body(fl!("replace-all")), widget::tooltip::Position::Top, ) .into(), ]) .align_y(Alignment::Center) .padding(space_xxs) .spacing(space_xxs); column = column.push(replace_widget); } column = column.push( widget::row::with_children(vec![ widget::checkbox(fl!("case-sensitive"), self.config.find_case_sensitive) .on_toggle(Message::FindCaseSensitive) .into(), widget::checkbox(fl!("use-regex"), self.config.find_use_regex) .on_toggle(Message::FindUseRegex) .into(), widget::checkbox(fl!("wrap-around"), self.config.find_wrap_around) .on_toggle(Message::FindWrapAround) .into(), ]) .align_y(Alignment::Center) .padding(space_xxs) .spacing(space_xxs), ); tab_column = tab_column .push(widget::layer_container(column).layer(cosmic_theme::Layer::Primary)); } let content: Element<_> = tab_column.into(); // Uncomment to debug layout: //content.explain(cosmic::iced::Color::WHITE) content } fn view_window(&self, window_id: window::Id) -> Element<'_, Message> { match &self.dialog_opt { Some(dialog) => dialog.view(window_id), None => widget::text("Unknown window ID").into(), } } fn subscription(&self) -> Subscription { struct WatcherSubscription; struct ConfigSubscription; struct ConfigStateSubscription; struct ThemeSubscription; let mut subscriptions = vec![ event::listen_with(|event, status, window_id| match event { event::Event::Keyboard(keyboard::Event::KeyPressed { modifiers, key, .. }) => { match status { event::Status::Ignored => Some(Message::Key(modifiers, key)), event::Status::Captured => None, } } event::Event::Keyboard(keyboard::Event::ModifiersChanged(modifiers)) => { Some(Message::Modifiers(modifiers)) } event::Event::Window(window::Event::Focused) => Some(Message::Focus(window_id)), event::Event::Window(window::Event::CloseRequested) => { Some(Message::CloseWindow(window_id)) } _ => None, }), Subscription::run_with_id( TypeId::of::(), stream::channel(100, |mut output| async move { let watcher_res = { let mut output = output.clone(); //TODO: debounce notify::recommended_watcher( move |event_res: Result| match event_res { Ok(event) => { match &event.kind { notify::EventKind::Access(_) | notify::EventKind::Modify( notify::event::ModifyKind::Metadata(_), ) => { // Data not mutated return; } _ => {} } match futures::executor::block_on(async { output.send(Message::NotifyEvent(event)).await }) { Ok(()) => {} Err(err) => { log::warn!("failed to send notify event: {:?}", err); } } } Err(err) => { log::warn!("failed to watch files: {:?}", err); } }, ) }; match watcher_res { Ok(watcher) => { match output .send(Message::NotifyWatcher(WatcherWrapper { watcher_opt: Some(watcher), })) .await { Ok(()) => {} Err(err) => { log::warn!("failed to send notify watcher: {:?}", err); } } } Err(err) => { log::warn!("failed to create file watcher: {:?}", err); } } //TODO: how to properly kill this task? loop { time::sleep(time::Duration::new(1, 0)).await; } }), ), cosmic_config::config_subscription( TypeId::of::(), Self::APP_ID.into(), CONFIG_VERSION, ) .map(|update| { for error in update.errors { log::error!("error loading config: {error:?}"); } Message::Config(update.config) }), cosmic_config::config_state_subscription( TypeId::of::(), Self::APP_ID.into(), CONFIG_VERSION, ) .map(|update| { for error in update.errors { log::error!("error loading config: {error:?}"); } Message::ConfigState(update.config) }), cosmic_config::config_subscription::<_, cosmic_theme::ThemeMode>( TypeId::of::(), cosmic_theme::THEME_MODE_ID.into(), cosmic_theme::ThemeMode::version(), ) .map(|update| { for error in update.errors { log::error!("error loading theme mode: {error:?}"); } Message::SystemThemeModeChange(update.config) }), match &self.dialog_opt { Some(dialog) => dialog.subscription(), None => Subscription::none(), }, ]; if let Some(auto_scroll) = self.auto_scroll { subscriptions.push( iced::time::every(time::Duration::from_millis(10)) .map(move |_| Message::Scroll(auto_scroll)), ); } Subscription::batch(subscriptions) } }