cosmic-edit/src/main.rs

2248 lines
86 KiB
Rust
Raw Normal View History

2023-02-10 07:42:05 -07:00
// SPDX-License-Identifier: GPL-3.0-only
2023-02-07 13:00:49 -07:00
use cosmic::{
app::{message, Command, Core, Settings},
2023-11-03 18:45:03 -06:00
cosmic_config::{self, CosmicConfigEntry},
cosmic_theme, executor,
font::Font,
2023-10-27 09:30:52 -06:00
iced::{
2023-11-21 11:57:11 -07:00
clipboard, event,
futures::{self, SinkExt},
2024-01-11 12:45:13 -07:00
keyboard::{self, Modifiers},
subscription,
widget::text,
2023-12-01 13:19:56 -07:00
window, Alignment, Background, Color, Length, Point,
2023-10-27 09:30:52 -06:00
},
2023-11-27 10:24:36 -07:00
style, theme,
2023-10-27 11:43:30 -06:00
widget::{self, button, icon, nav_bar, segmented_button, view_switcher},
2023-11-27 10:24:36 -07:00
Application, ApplicationExt, Apply, Element,
2023-02-07 13:00:49 -07:00
};
use cosmic_text::{Cursor, Edit, Family, FontSystem, Selection, SwashCache, SyntaxSystem, ViMode};
2024-01-19 11:25:50 -07:00
use serde::{Deserialize, Serialize};
2023-10-25 07:35:38 -06:00
use std::{
2023-11-27 09:02:35 -07:00
any::TypeId,
2024-01-19 11:25:50 -07:00
collections::HashMap,
env, fs, io,
2023-10-25 07:35:38 -06:00
path::{Path, PathBuf},
2023-11-01 08:50:05 -06:00
process,
2024-01-18 00:25:22 -05:00
sync::{Mutex, OnceLock},
2023-10-25 07:35:38 -06:00
};
2023-11-21 11:57:11 -07:00
use tokio::time;
2023-02-07 13:00:49 -07:00
2024-01-19 11:25:50 -07:00
use config::{AppTheme, Config, CONFIG_VERSION};
mod config;
2023-12-01 13:19:56 -07:00
use git::{GitDiff, GitDiffLine, GitRepository, GitStatus, GitStatusKind};
mod git;
2023-11-28 13:07:27 -07:00
use icon_cache::IconCache;
mod icon_cache;
2024-01-19 11:25:50 -07:00
use key_bind::{key_binds, KeyBind};
mod key_bind;
2023-11-30 14:24:58 -07:00
use line_number::LineNumberCache;
mod line_number;
2023-10-30 09:34:36 -06:00
mod localize;
2023-11-13 10:33:26 -07:00
pub use self::mime_icon::{mime_icon, FALLBACK_MIME_ICON};
mod mime_icon;
2023-10-26 10:15:09 -06:00
use self::menu::menu_bar;
mod menu;
2023-10-27 11:43:30 -06:00
use self::project::ProjectNode;
2023-10-26 10:15:09 -06:00
mod project;
use self::search::ProjectSearchResult;
mod search;
2023-12-01 13:19:56 -07:00
use self::tab::{EditorTab, GitDiffTab, Tab};
2023-10-26 10:15:09 -06:00
mod tab;
2023-02-09 15:13:38 -07:00
2023-02-07 13:00:49 -07:00
use self::text_box::text_box;
mod text_box;
2023-10-26 10:15:09 -06:00
//TODO: re-use iced FONT_SYSTEM
2024-01-18 00:25:22 -05:00
static FONT_SYSTEM: OnceLock<Mutex<FontSystem>> = OnceLock::new();
static ICON_CACHE: OnceLock<Mutex<IconCache>> = OnceLock::new();
static LINE_NUMBER_CACHE: OnceLock<Mutex<LineNumberCache>> = OnceLock::new();
static SWASH_CACHE: OnceLock<Mutex<SwashCache>> = OnceLock::new();
static SYNTAX_SYSTEM: OnceLock<SyntaxSystem> = 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)
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
FONT_SYSTEM.get_or_init(|| Mutex::new(FontSystem::new()));
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);
for (theme_name, theme_data) in &[
("COSMIC Dark", cosmic_syntax_theme::COSMIC_DARK_TM_THEME),
2024-01-18 00:25:22 -05:00
("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(theme) => {
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,
}
2024-01-18 00:25:22 -05:00
});
2023-11-28 13:07:27 -07:00
2023-12-08 08:50:23 -07:00
#[cfg(all(unix, not(target_os = "redox")))]
match fork::daemon(true, true) {
2023-11-27 14:46:10 -07:00
Ok(fork::Fork::Child) => (),
Ok(fork::Fork::Parent(_child_pid)) => process::exit(0),
Err(err) => {
eprintln!("failed to daemonize: {:?}", err);
process::exit(1);
}
}
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("warn")).init();
2023-02-07 13:00:49 -07:00
2023-10-30 09:34:36 -06:00
localize::localize();
2023-11-13 09:08:31 -07:00
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
});
2023-11-13 09:08:31 -07:00
(Some(config_handler), config)
}
Err(err) => {
log::error!("failed to create config handler: {}", err);
(None, Config::default())
}
};
let mut settings = Settings::default();
settings = settings.theme(config.app_theme.theme());
2023-11-15 14:52:40 -07:00
#[cfg(target_os = "redox")]
{
// Redox does not support resize if doing CSDs
settings = settings.client_decorations(false);
}
//TODO: allow size limits on iced_winit
//settings = settings.size_limits(Limits::NONE.min_width(400.0).min_height(200.0));
2023-11-13 09:08:31 -07:00
let flags = Flags {
config_handler,
config,
};
cosmic::app::run::<App>(settings, flags)?;
Ok(())
2023-02-07 13:00:49 -07:00
}
2024-01-19 11:25:50 -07:00
#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
pub enum Action {
CloseFile,
CloseProject,
Copy,
Cut,
Find,
FindAndReplace,
NewFile,
NewWindow,
OpenFileDialog,
OpenProjectDialog,
Paste,
Quit,
Redo,
Save,
SelectAll,
TabActivate0,
TabActivate1,
TabActivate2,
TabActivate3,
TabActivate4,
TabActivate5,
TabActivate6,
TabActivate7,
TabActivate8,
TabNext,
TabPrev,
2024-01-19 11:25:50 -07:00
ToggleGitManagement,
ToggleProjectSearch,
ToggleSettingsPage,
ToggleWordWrap,
Undo,
}
impl Action {
pub fn message(&self) -> Message {
match self {
Self::CloseFile => Message::CloseFile,
Self::CloseProject => Message::CloseProject,
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::Paste => Message::Paste,
Self::Quit => Message::Quit,
Self::Redo => Message::Redo,
Self::Save => Message::Save,
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,
2024-01-19 11:25:50 -07:00
Self::ToggleGitManagement => Message::ToggleContextPage(ContextPage::GitManagement),
Self::ToggleProjectSearch => Message::ToggleContextPage(ContextPage::ProjectSearch),
Self::ToggleSettingsPage => Message::ToggleContextPage(ContextPage::Settings),
Self::ToggleWordWrap => Message::ToggleWordWrap,
Self::Undo => Message::Undo,
}
}
}
2023-11-13 09:08:31 -07:00
#[derive(Clone, Debug)]
pub struct Flags {
config_handler: Option<cosmic_config::Config>,
config: Config,
}
2023-11-21 11:57:11 -07:00
#[derive(Debug)]
pub struct WatcherWrapper {
watcher_opt: Option<notify::RecommendedWatcher>,
}
impl Clone for WatcherWrapper {
fn clone(&self) -> Self {
Self { watcher_opt: None }
}
}
impl PartialEq for WatcherWrapper {
fn eq(&self, _other: &Self) -> bool {
false
}
}
2023-02-09 15:13:38 -07:00
#[allow(dead_code)]
2023-11-20 11:26:26 -07:00
#[derive(Clone, Debug, PartialEq)]
2023-02-09 15:13:38 -07:00
pub enum Message {
2023-11-13 09:08:31 -07:00
AppTheme(AppTheme),
2023-11-03 18:45:03 -06:00
Config(Config),
CloseFile,
CloseProject,
Copy,
2023-11-03 18:45:03 -06:00
Cut,
DefaultFont(usize),
2023-11-03 15:58:26 -06:00
DefaultFontSize(usize),
Find(Option<bool>),
FindNext,
FindPrevious,
2024-01-09 13:37:10 -07:00
FindReplace,
FindReplaceAll,
FindReplaceValueChanged(String),
FindSearchValueChanged(String),
2023-12-01 13:19:56 -07:00
GitProjectStatus(Vec<(String, PathBuf, Vec<GitStatus>)>),
2024-01-11 12:45:13 -07:00
Key(Modifiers, keyboard::KeyCode),
Modifiers(Modifiers),
2023-11-01 08:50:05 -06:00
NewFile,
NewWindow,
2023-11-21 11:57:11 -07:00
NotifyEvent(notify::Event),
NotifyWatcher(WatcherWrapper),
OpenFileDialog,
OpenFile(PathBuf),
2023-12-01 13:19:56 -07:00
OpenGitDiff(PathBuf, GitDiff),
OpenProjectDialog,
OpenProject(PathBuf),
OpenSearchResult(usize, usize),
Paste,
PasteValue(String),
2023-12-01 13:19:56 -07:00
PrepareGitDiff(PathBuf, PathBuf, bool),
ProjectSearchResult(ProjectSearchResult),
ProjectSearchSubmit,
ProjectSearchValue(String),
2023-11-01 08:50:05 -06:00
Quit,
2023-11-13 13:31:46 -07:00
Redo,
2023-02-09 15:13:38 -07:00
Save,
2023-11-06 09:41:29 -07:00
SelectAll,
2023-11-14 12:09:26 -07:00
SystemThemeModeChange(cosmic_theme::ThemeMode),
SyntaxTheme(usize, bool),
2023-03-14 14:55:50 -06:00
TabActivate(segmented_button::Entity),
2024-01-20 20:45:49 -05:00
TabActivateJump(usize),
2023-11-13 14:47:17 -07:00
TabChanged(segmented_button::Entity),
2023-03-14 14:55:50 -06:00
TabClose(segmented_button::Entity),
2023-11-20 11:26:26 -07:00
TabContextAction(segmented_button::Entity, Action),
TabContextMenu(segmented_button::Entity, Option<Point>),
2024-01-19 01:29:12 -05:00
TabNext,
TabPrev,
TabSetCursor(segmented_button::Entity, Cursor),
2023-11-16 08:44:23 -07:00
TabWidth(u16),
2023-02-09 15:13:38 -07:00
Todo,
2023-11-16 09:00:48 -07:00
ToggleAutoIndent,
ToggleContextPage(ContextPage),
2023-11-30 14:24:58 -07:00
ToggleLineNumbers,
2023-11-01 09:44:11 -06:00
ToggleWordWrap,
2023-11-13 13:31:46 -07:00
Undo,
VimBindings(bool),
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ContextPage {
2023-11-01 14:39:17 -06:00
DocumentStatistics,
2023-12-01 13:19:56 -07:00
GitManagement,
//TODO: Move search to pop-up
ProjectSearch,
Settings,
}
impl ContextPage {
fn title(&self) -> String {
match self {
2023-11-01 14:39:17 -06:00
Self::DocumentStatistics => fl!("document-statistics"),
2023-12-01 13:19:56 -07:00
Self::GitManagement => fl!("git-management"),
Self::ProjectSearch => fl!("project-search"),
Self::Settings => fl!("settings"),
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum Find {
None,
Find,
FindAndReplace,
}
pub struct App {
core: Core,
nav_model: segmented_button::SingleSelectModel,
tab_model: segmented_button::SingleSelectModel,
2023-11-03 18:45:03 -06:00
config_handler: Option<cosmic_config::Config>,
config: Config,
2024-01-19 11:25:50 -07:00
key_binds: HashMap<KeyBind, Action>,
2023-11-13 09:08:31 -07:00
app_themes: Vec<String>,
font_names: Vec<String>,
2023-11-03 15:58:26 -06:00
font_size_names: Vec<String>,
font_sizes: Vec<u16>,
theme_names: Vec<String>,
context_page: ContextPage,
text_box_id: widget::Id,
find_opt: Option<bool>,
2024-01-09 13:37:10 -07:00
find_replace_id: widget::Id,
find_replace_value: String,
find_search_id: widget::Id,
find_search_value: String,
2023-12-01 13:19:56 -07:00
git_project_status: Option<Vec<(String, PathBuf, Vec<GitStatus>)>>,
projects: Vec<(String, PathBuf)>,
2023-11-29 15:41:22 -07:00
project_search_id: widget::Id,
project_search_value: String,
project_search_result: Option<ProjectSearchResult>,
2023-11-21 11:57:11 -07:00
watcher_opt: Option<notify::RecommendedWatcher>,
2024-01-11 12:45:13 -07:00
modifiers: Modifiers,
2023-02-09 15:13:38 -07:00
}
impl App {
2023-02-09 15:13:38 -07:00
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()
}
2023-02-07 13:00:49 -07:00
2023-10-27 11:43:30 -06:00
fn open_folder<P: AsRef<Path>>(&mut self, path: P, mut position: u16, indent: u16) {
let read_dir = match fs::read_dir(&path) {
Ok(ok) => ok,
2023-10-25 07:35:38 -06:00
Err(err) => {
2023-10-27 11:43:30 -06:00
log::error!("failed to read directory {:?}: {}", path.as_ref(), err);
return;
2023-10-25 07:35:38 -06:00
}
2023-10-27 11:43:30 -06:00
};
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);
}
2023-10-27 12:28:25 -06:00
nodes.sort();
2023-10-27 11:43:30 -06:00
for node in nodes {
self.nav_model
.insert()
.position(position)
.indent(indent)
2023-11-13 10:33:26 -07:00
.icon(node.icon(16))
2023-10-27 11:43:30 -06:00
.text(node.name().to_string())
.data(node);
position += 1;
2023-10-25 07:35:38 -06:00
}
}
2023-10-27 11:43:30 -06:00
pub fn open_project<P: AsRef<Path>>(&mut self, path: P) {
2023-12-01 13:19:56 -07:00
let path = path.as_ref();
let node = match ProjectNode::new(path) {
2023-10-27 12:28:25 -06:00
Ok(mut node) => {
match &mut node {
2023-12-01 13:19:56 -07:00
ProjectNode::Folder {
name,
path,
open,
root,
} => {
2023-10-27 12:28:25 -06:00
*open = true;
*root = true;
2023-12-01 13:19:56 -07:00
// Save the absolute path
self.projects.push((name.to_string(), path.to_path_buf()));
2023-10-27 12:28:25 -06:00
}
_ => {
2023-12-01 13:19:56 -07:00
log::error!("failed to open project {:?}: not a directory", path);
2023-10-27 12:28:25 -06:00
return;
}
}
node
}
2023-10-27 11:43:30 -06:00
Err(err) => {
2023-12-01 13:19:56 -07:00
log::error!("failed to open project {:?}: {}", path, err);
2023-10-27 11:43:30 -06:00
return;
}
};
let id = self
.nav_model
.insert()
2023-11-13 10:33:26 -07:00
.icon(node.icon(16))
2023-10-27 12:28:25 -06:00
.text(node.name().to_string())
.data(node)
2023-10-27 11:43:30 -06:00
.id();
let position = self.nav_model.position(id).unwrap_or(0);
self.open_folder(path, position + 1, 1);
2023-10-27 11:43:30 -06:00
}
pub fn open_tab(&mut self, path_opt: Option<PathBuf>) -> Option<segmented_button::Entity> {
let tab = 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::<Tab>(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(entity);
}
2023-12-01 13:19:56 -07:00
let mut tab = EditorTab::new(&self.config);
tab.open(canonical);
2023-11-21 11:57:11 -07:00
tab.watch(&mut self.watcher_opt);
tab
}
2023-12-01 13:19:56 -07:00
None => EditorTab::new(&self.config),
};
Some(
self.tab_model
.insert()
.text(tab.title())
.icon(tab.icon(16))
2023-12-01 13:19:56 -07:00
.data::<Tab>(Tab::Editor(tab))
.closable()
.activate()
.id(),
)
2023-02-07 13:00:49 -07:00
}
2023-11-13 09:08:31 -07:00
fn update_config(&mut self) -> Command<Message> {
//TODO: provide iterator over data
let entities: Vec<_> = self.tab_model.iter().collect();
for entity in entities {
2023-12-01 13:19:56 -07:00
if let Some(Tab::Editor(tab)) = self.tab_model.data_mut::<Tab>(entity) {
tab.set_config(&self.config);
}
}
2023-11-13 09:08:31 -07:00
cosmic::app::command::set_theme(self.config.app_theme.theme())
}
2023-11-13 09:08:31 -07:00
fn save_config(&mut self) -> Command<Message> {
if let Some(ref config_handler) = self.config_handler {
if let Err(err) = self.config.write_entry(config_handler) {
log::error!("failed to save config: {}", err);
}
}
2023-11-13 09:08:31 -07:00
self.update_config()
2023-11-03 15:58:26 -06:00
}
fn update_focus(&self) -> Command<Message> {
if self.core.window.show_context {
match self.context_page {
ContextPage::ProjectSearch => {
widget::text_input::focus(self.project_search_id.clone())
}
_ => Command::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() {
2023-12-01 13:19:56 -07:00
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?
// Command not used becuase opening a folder just returns Command::none
let _ = self.on_nav_select(id);
}
None => {
break;
}
}
}
}
self.nav_model.activate(active_id);
}
2023-10-30 10:29:45 -06:00
// Call this any time the tab changes
pub fn update_tab(&mut self) -> Command<Message> {
self.update_nav_bar_active();
let title = match self.active_tab() {
2023-11-28 10:50:22 -07:00
Some(tab) => {
2023-12-01 13:19:56 -07:00
if let Tab::Editor(inner) = tab {
// Force redraw on tab switches
2023-12-19 12:07:45 -07:00
inner.editor.lock().unwrap().set_redraw(true);
2023-12-01 13:19:56 -07:00
}
2023-11-28 10:50:22 -07:00
tab.title()
}
None => "No Open File".to_string(),
};
let window_title = format!("{title} - COSMIC Text Editor");
2023-10-19 10:33:42 -06:00
self.set_header_title(title.clone());
Command::batch([self.set_window_title(window_title), 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;
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() {
//TODO: do graphemes?
for c in line.text().chars() {
character_count += 1;
if !c.is_whitespace() {
character_count_no_spaces += 1;
}
}
}
});
}
widget::settings::view_column(vec![widget::settings::view_section("")
.add(widget::settings::item::builder(fl!("word-count")).control("TODO"))
.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("[+]").style(theme::Text::Color(success_color.into()));
let deleted =
|| widget::text("[-]").style(theme::Text::Color(destructive_color.into()));
let modified = || widget::text("[*]").style(theme::Text::Color(warning_color.into()));
let mut items = Vec::with_capacity(project_status.len().saturating_mul(3));
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(
widget::row::with_children(vec![
icon.into(),
widget::text(text.clone()).into(),
])
.spacing(spacing.space_xs),
)
.on_press(Message::PrepareGitDiff(
project_path.clone(),
item.path.clone(),
false,
))
.style(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(
widget::row::with_children(vec![
icon.into(),
widget::text(text.clone()).into(),
])
.spacing(spacing.space_xs),
)
.on_press(Message::PrepareGitDiff(
project_path.clone(),
item.path.clone(),
true,
))
.style(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::view_section(fl!("unstaged-changes"))
.add(widget::column::with_children(unstaged_items))
.into(),
);
}
if !staged_items.is_empty() {
items.push(
widget::settings::view_section(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::text("TODO (TRANSLATE): Loading git status...").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(
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)
.style(theme::Button::AppletMenu),
);
}
items.push(
widget::settings::view_section(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 = {
2024-01-18 00:25:22 -05:00
let font_system = FONT_SYSTEM.get().unwrap().lock().unwrap();
let current_font_name = font_system.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);
widget::settings::view_column(vec![
widget::settings::view_section(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)
}),
),
)
.into(),
widget::settings::view_section(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.
2023-11-13 09:08:31 -07:00
impl Application for App {
/// Default async executor to use with the app.
type Executor = executor::Default;
/// Argument received [`cosmic::Application::new`].
2023-11-13 09:08:31 -07:00
type Flags = Flags;
/// Message type specific to our [`App`].
type Message = Message;
/// The unique application ID to supply to the window manager.
2023-10-27 14:59:41 -06:00
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.
2023-11-13 09:08:31 -07:00
fn init(core: Core, flags: Self::Flags) -> (Self, Command<Self::Message>) {
2023-11-03 19:00:41 -06:00
// Update font name from config
{
2024-01-18 00:25:22 -05:00
let mut font_system = FONT_SYSTEM.get().unwrap().lock().unwrap();
2023-11-13 09:08:31 -07:00
font_system
.db_mut()
.set_monospace_family(&flags.config.font_name);
2023-11-03 19:00:41 -06:00
}
2023-11-13 09:08:31 -07:00
let app_themes = vec![fl!("match-desktop"), fl!("dark"), fl!("light")];
let font_names = {
let mut font_names = Vec::new();
2024-01-18 00:25:22 -05:00
let font_system = FONT_SYSTEM.get().unwrap().lock().unwrap();
//TODO: do not repeat, used in Tab::new
let attrs = cosmic_text::Attrs::new().family(Family::Monospace);
for face in font_system.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());
font_names.push(font_name);
}
}
font_names.sort();
font_names
};
2023-11-03 15:58:26 -06:00
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);
}
2024-01-18 00:25:22 -05:00
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 app = App {
core,
2023-10-27 11:43:30 -06:00
nav_model: nav_bar::Model::builder().build(),
tab_model: segmented_button::Model::builder().build(),
2023-11-13 09:08:31 -07:00
config_handler: flags.config_handler,
config: flags.config,
2024-01-19 11:25:50 -07:00
key_binds: key_binds(),
2023-11-13 09:08:31 -07:00
app_themes,
font_names,
2023-11-03 15:58:26 -06:00
font_size_names,
font_sizes,
theme_names,
context_page: ContextPage::Settings,
text_box_id: widget::Id::unique(),
find_opt: None,
2024-01-09 13:37:10 -07:00
find_replace_id: widget::Id::unique(),
find_replace_value: String::new(),
find_search_id: widget::Id::unique(),
find_search_value: String::new(),
2023-12-01 13:19:56 -07:00
git_project_status: None,
projects: Vec::new(),
2023-11-29 15:41:22 -07:00
project_search_id: widget::Id::unique(),
project_search_value: String::new(),
project_search_result: None,
2023-11-21 11:57:11 -07:00
watcher_opt: None,
2024-01-11 12:45:13 -07:00
modifiers: Modifiers::empty(),
};
2023-10-25 07:35:38 -06:00
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));
}
2023-02-07 13:00:49 -07:00
}
2023-10-27 11:43:30 -06:00
// Show nav bar only if project is provided
if app.core.nav_bar_active() != app.nav_model.iter().next().is_some() {
app.core.nav_bar_toggle();
app.nav_model
.insert()
2023-11-28 13:07:27 -07:00
.icon(icon_cache_get("folder-open-symbolic", 16))
2023-10-30 09:34:36 -06:00
.text(fl!("open-project"));
2023-10-27 11:43:30 -06:00
}
// Open an empty file if no arguments provided
if app.tab_model.iter().next().is_none() {
app.open_tab(None);
}
2023-11-13 09:08:31 -07:00
//TODO: try update_config here? It breaks loading system theme by default
2023-10-30 10:29:45 -06:00
let command = app.update_tab();
(app, command)
2023-02-07 13:00:49 -07:00
}
2023-11-27 10:24:36 -07:00
// The default nav_bar widget needs to be condensed for cosmic-edit
fn nav_bar(&self) -> Option<Element<message::Message<Self::Message>>> {
if !self.core().nav_bar_active() {
return None;
}
let nav_model = self.nav_model()?;
2023-11-30 11:02:05 -07:00
let cosmic_theme::Spacing {
space_none,
space_s,
space_xxxs,
..
} = self.core().system_theme().cosmic().spacing;
2023-11-27 10:24:36 -07:00
let mut nav = segmented_button::vertical(nav_model)
2023-11-30 11:02:05 -07:00
.button_height(space_xxxs + 20 /* line height */ + space_xxxs)
.button_padding([space_s, space_xxxs, space_s, space_xxxs])
.button_spacing(space_xxxs)
2023-11-27 10:24:36 -07:00
.on_activate(|entity| message::cosmic(cosmic::app::cosmic::Message::NavBar(entity)))
2023-11-30 11:02:05 -07:00
.spacing(space_none)
2023-11-27 10:24:36 -07:00
.style(theme::SegmentedButton::ViewSwitcher)
.apply(widget::container)
2023-11-30 11:02:05 -07:00
.padding(space_s)
2023-11-27 10:24:36 -07:00
.width(Length::Fill);
if !self.core().is_condensed() {
nav = nav.max_width(300);
}
Some(
nav.apply(widget::scrollable)
.apply(widget::container)
.height(Length::Fill)
.style(theme::Container::custom(nav_bar::nav_bar_style))
.into(),
)
}
2023-10-27 11:43:30 -06:00
fn nav_model(&self) -> Option<&nav_bar::Model> {
Some(&self.nav_model)
}
fn on_context_drawer(&mut self) -> Command<Message> {
// Focus correct widget
self.update_focus()
}
//TODO: currently the first escape unfocuses, and the second calls this function
fn on_escape(&mut self) -> Command<Message> {
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()
}
2023-10-27 11:43:30 -06:00
fn on_nav_select(&mut self, id: nav_bar::Id) -> Command<Message> {
// Toggle open state and get clone of node data
let node_opt = match self.nav_model.data_mut::<ProjectNode>(id) {
2023-10-27 11:43:30 -06:00
Some(node) => {
if let ProjectNode::Folder { open, .. } = node {
*open = !*open;
2023-10-27 11:43:30 -06:00
}
Some(node.clone())
2023-10-27 11:43:30 -06:00
}
None => None,
2023-10-27 11:43:30 -06:00
};
match node_opt {
Some(node) => {
// Update icon
2023-11-13 10:33:26 -07:00
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);
2023-10-27 11:43:30 -06:00
} 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;
}
}
2023-10-27 11:43:30 -06:00
}
Command::none()
}
ProjectNode::File { path, .. } => {
//TODO: go to already open file if possible
self.update(Message::OpenFile(path))
2023-10-27 11:43:30 -06:00
}
}
}
None => {
// Open project
self.update(Message::OpenProjectDialog)
2023-10-27 11:43:30 -06:00
}
}
}
fn update(&mut self, message: Message) -> Command<Message> {
2023-02-07 13:00:49 -07:00
match message {
2023-11-13 09:08:31 -07:00
Message::AppTheme(app_theme) => {
self.config.app_theme = app_theme;
return self.save_config();
}
2023-11-03 18:45:03 -06:00
Message::Config(config) => {
if config != self.config {
log::info!("update config");
//TODO: update syntax theme by clearing tabs, only if needed
self.config = config;
2023-11-13 09:08:31 -07:00
return self.update_config();
2023-11-03 18:45:03 -06:00
}
}
Message::CloseFile => {
return self.update(Message::TabClose(self.tab_model.active()));
}
Message::CloseProject => {
log::info!("TODO");
}
Message::Copy => {
if let Some(Tab::Editor(tab)) = self.active_tab() {
2023-11-03 18:45:03 -06:00
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() {
2023-11-03 18:45:03 -06:00
let mut editor = tab.editor.lock().unwrap();
let selection_opt = editor.copy_selection();
2023-12-12 09:54:49 -07:00
editor.start_change();
2023-11-03 18:45:03 -06:00
editor.delete_selection();
2023-12-12 09:54:49 -07:00
editor.finish_change();
if let Some(selection) = selection_opt {
return clipboard::write(selection);
}
}
}
Message::DefaultFont(index) => {
match self.font_names.get(index) {
Some(font_name) => {
2023-11-03 19:00:41 -06:00
if font_name != &self.config.font_name {
// Update font name from config
{
2024-01-18 00:25:22 -05:00
let mut font_system = FONT_SYSTEM.get().unwrap().lock().unwrap();
2023-11-03 19:00:41 -06:00
font_system.db_mut().set_monospace_family(font_name);
}
2023-11-30 14:24:58 -07:00
// Reset line number cache
{
2024-01-18 00:25:22 -05:00
let mut line_number_cache =
LINE_NUMBER_CACHE.get().unwrap().lock().unwrap();
2023-11-30 14:24:58 -07:00
line_number_cache.clear();
}
2023-11-03 19:00:41 -06:00
// This does a complete reset of shaping data!
let entities: Vec<_> = self.tab_model.iter().collect();
for entity in entities {
2023-12-01 13:19:56 -07:00
if let Some(Tab::Editor(tab)) =
self.tab_model.data_mut::<Tab>(entity)
{
2023-11-03 19:00:41 -06:00
let mut editor = tab.editor.lock().unwrap();
2023-12-19 12:07:45 -07:00
editor.with_buffer_mut(|buffer| {
for line in buffer.lines.iter_mut() {
line.reset();
}
});
}
}
2023-11-03 19:00:41 -06:00
self.config.font_name = font_name.to_string();
2023-11-13 09:08:31 -07:00
return self.save_config();
}
}
None => {
log::warn!("failed to find font with index {}", index);
}
}
}
2023-11-03 16:16:24 -06:00
Message::DefaultFontSize(index) => match self.font_sizes.get(index) {
Some(font_size) => {
self.config.font_size = *font_size;
2023-11-13 09:08:31 -07:00
return self.save_config();
2023-11-03 15:58:26 -06:00
}
2023-11-03 16:16:24 -06:00
None => {
log::warn!("failed to find font with index {}", index);
}
},
Message::Find(find_opt) => {
self.find_opt = find_opt;
// Focus correct input
return self.update_focus();
}
Message::FindNext => {
if !self.find_search_value.is_empty() {
if let Some(Tab::Editor(tab)) = self.active_tab() {
tab.search(&self.find_search_value, true);
}
}
// 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() {
tab.search(&self.find_search_value, false);
}
}
// Focus correct input
return self.update_focus();
}
2024-01-09 13:37:10 -07:00
Message::FindReplace => {
if !self.find_search_value.is_empty() {
if let Some(Tab::Editor(tab)) = self.active_tab() {
tab.replace(&self.find_search_value, &self.find_replace_value);
}
}
// Focus correct input
return self.update_focus();
}
Message::FindReplaceAll => {
2024-01-09 13:40:55 -07:00
if !self.find_search_value.is_empty() {
if let Some(Tab::Editor(tab)) = self.active_tab() {
{
let mut editor = tab.editor.lock().unwrap();
editor.set_cursor(cosmic_text::Cursor::new(0, 0));
}
while tab.replace(&self.find_search_value, &self.find_replace_value) {}
}
}
2024-01-09 13:37:10 -07:00
// Focus correct input
return self.update_focus();
}
Message::FindReplaceValueChanged(value) => {
self.find_replace_value = value;
}
Message::FindSearchValueChanged(value) => {
self.find_search_value = value;
}
2023-12-01 13:19:56 -07:00
Message::GitProjectStatus(project_status) => {
self.git_project_status = Some(project_status);
}
2023-11-03 16:16:24 -06:00
Message::Key(modifiers, key_code) => {
2024-01-19 11:25:50 -07:00
for (key_bind, action) in self.key_binds.iter() {
2023-11-03 16:16:24 -06:00
if key_bind.matches(modifiers, key_code) {
return self.update(action.message());
}
}
}
2024-01-11 12:45:13 -07:00
Message::Modifiers(modifiers) => {
self.modifiers = modifiers;
}
2023-11-01 08:50:05 -06:00
Message::NewFile => {
2023-10-25 21:11:08 -06:00
self.open_tab(None);
2023-10-30 10:29:45 -06:00
return self.update_tab();
2023-10-25 21:11:08 -06:00
}
2023-11-01 08:50:05 -06:00
Message::NewWindow => {
//TODO: support multi-window in winit
match env::current_exe() {
Ok(exe) => match process::Command::new(&exe).spawn() {
Ok(_child) => {}
2023-11-01 08:50:05 -06:00
Err(err) => {
log::error!("failed to execute {:?}: {}", exe, err);
}
},
Err(err) => {
log::error!("failed to get current executable path: {}", err);
}
}
}
2023-11-21 11:57:11 -07:00
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::<Tab>(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);
2023-11-21 11:57:11 -07:00
}
}
}
}
}
for entity in needs_reload {
match self.tab_model.data_mut::<Tab>(entity) {
2023-12-01 13:19:56 -07:00
Some(Tab::Editor(tab)) => {
2023-11-21 11:57:11 -07:00
tab.reload();
}
2023-12-01 13:19:56 -07:00
_ => {
2023-11-21 11:57:11 -07:00
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::<Tab>(entity) {
tab.watch(&mut self.watcher_opt);
2023-11-21 11:57:11 -07:00
}
}
}
None => {
log::warn!("message did not contain notify watcher");
}
},
Message::OpenFileDialog => {
#[cfg(feature = "rfd")]
return Command::perform(
async {
if let Some(handle) = rfd::AsyncFileDialog::new().pick_file().await {
message::app(Message::OpenFile(handle.path().to_owned()))
} else {
message::none()
}
},
|x| x,
);
}
Message::OpenFile(path) => {
self.open_tab(Some(path));
2023-10-30 10:29:45 -06:00
return self.update_tab();
2023-02-10 07:34:43 -07:00
}
2023-12-01 13:19:56 -07:00
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 = mime_icon(&diff.path, 16);
let tab = Tab::GitDiff(GitDiffTab { title, diff });
self.tab_model
.insert()
.text(tab.title())
.icon(icon)
.data::<Tab>(tab)
.closable()
.activate();
return self.update_tab();
}
Message::OpenProjectDialog => {
2023-11-15 09:06:32 -07:00
#[cfg(feature = "rfd")]
return Command::perform(
async {
if let Some(handle) = rfd::AsyncFileDialog::new().pick_folder().await {
message::app(Message::OpenProject(handle.path().to_owned()))
} else {
message::none()
}
},
|x| x,
);
}
Message::OpenProject(path) => {
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 Command::batch([
//TODO: why must this be done in a command?
Command::perform(
async move { message::app(Message::TabSetCursor(entity, cursor)) },
|x| x,
),
self.update_tab(),
]);
}
}
}
Message::Paste => {
return clipboard::read(|value_opt| match value_opt {
Some(value) => message::app(Message::PasteValue(value)),
None => message::none(),
});
}
Message::PasteValue(value) => {
if let Some(Tab::Editor(tab)) = self.active_tab() {
let mut editor = tab.editor.lock().unwrap();
2023-12-12 09:53:39 -07:00
editor.start_change();
editor.insert_string(&value, None);
2023-12-12 09:53:39 -07:00
editor.finish_change();
}
}
2023-12-01 13:19:56 -07:00
Message::PrepareGitDiff(project_path, path, staged) => {
return Command::perform(
async move {
//TODO: send errors to UI
match GitRepository::new(&project_path) {
Ok(repo) => match repo.diff(&path, staged).await {
Ok(diff) => {
return message::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
);
}
}
message::none()
},
|x| x,
);
}
Message::ProjectSearchResult(project_search_result) => {
self.project_search_result = Some(project_search_result);
2023-11-29 15:41:22 -07:00
// Focus correct input
return self.update_focus();
}
Message::ProjectSearchSubmit => {
2023-11-30 10:39:13 -07:00
//TODO: Figure out length requirements?
if !self.project_search_value.is_empty() {
2023-12-01 13:19:56 -07:00
let projects = self.projects.clone();
2023-11-30 10:39:13 -07:00
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 Command::perform(
async move {
let task_res = tokio::task::spawn_blocking(move || {
2023-12-01 13:19:56 -07:00
project_search_result.search_projects(projects);
2023-11-30 10:39:13 -07:00
message::app(Message::ProjectSearchResult(project_search_result))
})
.await;
match task_res {
Ok(message) => message,
Err(err) => {
log::error!("failed to run search task: {}", err);
message::none()
}
}
2023-11-30 10:39:13 -07:00
},
|x| x,
);
}
}
Message::ProjectSearchValue(value) => {
self.project_search_value = value;
}
2023-11-01 08:50:05 -06:00
Message::Quit => {
//TODO: prompt for save?
return window::close(window::Id::MAIN);
2023-11-01 08:50:05 -06:00
}
Message::Redo => {
if let Some(Tab::Editor(tab)) = self.active_tab() {
2023-11-13 13:31:46 -07:00
let mut editor = tab.editor.lock().unwrap();
editor.redo();
}
}
2023-02-07 13:00:49 -07:00
Message::Save => {
2023-02-09 16:18:13 -07:00
let mut title_opt = None;
if let Some(Tab::Editor(tab)) = self.active_tab_mut() {
#[cfg(feature = "rfd")]
if tab.path_opt.is_none() {
//TODO: use async file dialog
tab.path_opt = rfd::FileDialog::new().save_file();
2023-02-10 07:34:43 -07:00
}
title_opt = Some(tab.title());
tab.save();
2023-02-07 13:00:49 -07:00
}
2023-02-09 16:18:13 -07:00
if let Some(title) = title_opt {
self.tab_model.text_set(self.tab_model.active(), title);
}
2023-02-10 07:34:43 -07:00
}
2023-11-06 09:41:29 -07:00
Message::SelectAll => {
if let Some(Tab::Editor(tab)) = self.active_tab_mut() {
let mut editor = tab.editor.lock().unwrap();
2023-11-06 09:41:29 -07:00
// Set cursor to lowest possible value
editor.set_cursor(Cursor::new(0, 0));
2023-11-06 09:41:29 -07:00
// 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);
2023-11-06 09:41:29 -07:00
}
}
2023-11-14 12:09:26 -07:00
Message::SystemThemeModeChange(_theme_mode) => {
return self.update_config();
}
Message::SyntaxTheme(index, dark) => match self.theme_names.get(index) {
Some(theme_name) => {
if dark {
self.config.syntax_theme_dark = theme_name.to_string();
} else {
self.config.syntax_theme_light = theme_name.to_string();
}
2023-11-13 09:08:31 -07:00
return self.save_config();
}
None => {
log::warn!("failed to find syntax theme with index {}", index);
}
},
Message::TabActivate(entity) => {
self.tab_model.activate(entity);
2023-10-30 10:29:45 -06:00
return self.update_tab();
}
2024-01-20 20:45:49 -05:00
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
2024-01-20 20:45:49 -05:00
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::<Tab>(entity) {
2023-11-13 14:47:17 -07:00
let mut title = tab.title();
//TODO: better way of adding change indicator
title.push_str(" \u{2022}");
self.tab_model.text_set(entity, title);
}
}
Message::TabClose(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);
}
2023-10-30 10:29:45 -06:00
return self.update_tab();
}
2023-11-20 11:26:26 -07:00
Message::TabContextAction(entity, action) => {
if let Some(Tab::Editor(tab)) = self.tab_model.data_mut::<Tab>(entity) {
// Close context menu
tab.context_menu = None;
// Run action's message
return self.update(action.message());
2023-11-20 11:26:26 -07:00
}
}
Message::TabContextMenu(entity, position_opt) => {
if let Some(Tab::Editor(tab)) = self.tab_model.data_mut::<Tab>(entity) {
// Update context menu
tab.context_menu = position_opt;
2023-11-20 11:26:26 -07:00
}
}
2024-01-19 01:29:12 -05:00
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::<Tab>(entity) {
let mut editor = tab.editor.lock().unwrap();
editor.set_cursor(cursor);
}
}
2023-11-16 08:44:23 -07:00
Message::TabWidth(tab_width) => {
self.config.tab_width = tab_width;
return self.save_config();
}
2023-02-09 15:13:38 -07:00
Message::Todo => {
log::warn!("TODO");
2023-02-10 07:34:43 -07:00
}
2023-11-16 09:00:48 -07:00
Message::ToggleAutoIndent => {
self.config.auto_indent = !self.config.auto_indent;
return self.save_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;
}
self.set_context_title(context_page.title());
2023-11-29 15:41:22 -07:00
2023-12-01 13:19:56 -07:00
// Execute commands for specific pages
2024-01-14 00:16:15 -05:00
if self.core.window.show_context && self.context_page == ContextPage::GitManagement
{
self.git_project_status = None;
let projects = self.projects.clone();
return Command::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,
));
2023-12-01 13:19:56 -07:00
}
}
2024-01-14 00:16:15 -05:00
Err(err) => {
log::error!(
"failed to get status of {:?}: {}",
project_path,
err
);
}
},
Err(err) => {
log::error!(
"failed to open repository {:?}: {}",
project_path,
err
);
2023-12-01 13:19:56 -07:00
}
2024-01-14 00:16:15 -05:00
}
}
message::app(Message::GitProjectStatus(project_status))
},
|x| x,
);
2023-11-29 15:41:22 -07:00
}
// Ensure focus of correct input
return self.update_focus();
}
2023-11-30 14:24:58 -07:00
Message::ToggleLineNumbers => {
self.config.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 {
2023-12-01 13:19:56 -07:00
if let Some(Tab::Editor(tab)) = self.tab_model.data_mut::<Tab>(entity) {
2023-11-30 14:24:58 -07:00
let mut editor = tab.editor.lock().unwrap();
2023-12-19 12:07:45 -07:00
editor.set_redraw(true);
2023-11-30 14:24:58 -07:00
}
}
return self.save_config();
}
2023-11-01 09:44:11 -06:00
Message::ToggleWordWrap => {
self.config.word_wrap = !self.config.word_wrap;
2023-11-13 09:08:31 -07:00
return self.save_config();
}
Message::Undo => {
if let Some(Tab::Editor(tab)) = self.active_tab() {
2023-11-13 13:31:46 -07:00
let mut editor = tab.editor.lock().unwrap();
editor.undo();
}
}
Message::VimBindings(vim_bindings) => {
self.config.vim_bindings = vim_bindings;
2023-11-13 09:08:31 -07:00
return self.save_config();
2023-10-27 09:30:52 -06:00
}
2023-02-07 13:00:49 -07:00
}
Command::none()
}
fn context_drawer(&self) -> Option<Element<Message>> {
if !self.core.window.show_context {
return None;
}
Some(match self.context_page {
ContextPage::DocumentStatistics => self.document_statistics(),
ContextPage::GitManagement => self.git_management(),
ContextPage::ProjectSearch => self.project_search(),
ContextPage::Settings => self.settings(),
})
}
2023-10-26 11:25:14 -06:00
fn header_start(&self) -> Vec<Element<Message>> {
2024-01-19 11:25:50 -07:00
vec![menu_bar(&self.config, &self.key_binds)]
2023-10-26 11:25:14 -06:00
}
2023-02-09 15:13:38 -07:00
2023-10-26 11:25:14 -06:00
fn view(&self) -> Element<Message> {
2023-11-30 11:02:05 -07:00
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_items(Alignment::Center)
.push(
view_switcher::horizontal(&self.tab_model)
.button_height(32)
.button_spacing(space_xxs)
.close_icon(icon_cache_get("window-close-symbolic", 16))
.on_activate(Message::TabActivate)
.on_close(Message::TabClose)
.width(Length::Shrink),
)
.push(
button(icon_cache_get("list-add-symbolic", 16))
.on_press(Message::NewFile)
.padding(space_xxs)
.style(style::Button::Icon),
),
);
2023-11-20 11:26:26 -07:00
let tab_id = self.tab_model.active();
match self.tab_model.data::<Tab>(tab_id) {
2023-12-01 13:19:56 -07:00
Some(Tab::Editor(tab)) => {
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}|")
}
}
}
};
2023-11-30 14:24:58 -07:00
let mut text_box = text_box(&tab.editor, self.config.metrics())
.id(self.text_box_id.clone())
2023-11-20 11:26:26 -07:00
.on_changed(Message::TabChanged(tab_id))
.has_context_menu(tab.context_menu.is_some())
2023-11-20 11:26:26 -07:00
.on_context_menu(move |position_opt| {
Message::TabContextMenu(tab_id, position_opt)
});
2023-11-30 14:24:58 -07:00
if self.config.line_numbers {
text_box = text_box.line_numbers();
}
let mut popover =
2024-01-19 11:25:50 -07:00
widget::popover(text_box, menu::context_menu(&self.key_binds, tab_id));
popover = match tab.context_menu {
Some(position) => popover.position(position),
None => popover.show_popup(false),
2023-11-20 11:26:26 -07:00
};
tab_column = tab_column.push(popover);
tab_column = tab_column.push(text(status).font(Font::MONOSPACE));
}
2023-12-01 13:19:56 -07:00
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::Container::Custom(Box::new(|_theme| {
//TODO: theme this color
widget::container::Appearance {
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::Container::Custom(Box::new(|_theme| {
//TODO: theme this color
widget::container::Appearance {
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::cosmic_container::container(diff_widget)
.layer(cosmic_theme::Layer::Primary),
));
}
2023-12-01 13:19:56 -07:00
None => {}
}
if let Some(replace) = &self.find_opt {
2024-01-09 13:37:10 -07:00
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)
2024-01-11 12:45:13 -07:00
.on_submit(if self.modifiers.contains(Modifiers::SHIFT) {
Message::FindPrevious
} else {
Message::FindNext
})
.width(Length::Fixed(320.0))
.trailing_icon(
button(icon_cache_get("edit-clear-symbolic", 16))
.on_press(Message::FindSearchValueChanged(String::new()))
.style(style::Button::Icon)
.into(),
);
let find_widget = widget::row::with_children(vec![
2024-01-09 13:37:10 -07:00
find_input.into(),
2024-01-09 13:09:18 -07:00
widget::tooltip(
button(icon_cache_get("go-up-symbolic", 16))
.on_press(Message::FindPrevious)
.padding(space_xxs)
.style(style::Button::Icon),
fl!("find-previous"),
widget::tooltip::Position::Top,
)
.into(),
widget::tooltip(
button(icon_cache_get("go-down-symbolic", 16))
.on_press(Message::FindNext)
.padding(space_xxs)
.style(style::Button::Icon),
fl!("find-next"),
widget::tooltip::Position::Top,
)
.into(),
widget::horizontal_space(Length::Fill).into(),
button(icon_cache_get("window-close-symbolic", 16))
.on_press(Message::Find(None))
.padding(space_xxs)
.style(style::Button::Icon)
.into(),
])
.align_items(Alignment::Center)
.padding(space_xxs)
.spacing(space_xxs);
2024-01-09 13:37:10 -07:00
let mut column = widget::column::with_capacity(2).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(icon_cache_get("edit-clear-symbolic", 16))
.on_press(Message::FindReplaceValueChanged(String::new()))
.style(style::Button::Icon)
.into(),
);
let replace_widget = widget::row::with_children(vec![
replace_input.into(),
widget::tooltip(
button(icon_cache_get("replace-symbolic", 16))
.on_press(Message::FindReplace)
.padding(space_xxs)
.style(style::Button::Icon),
fl!("replace"),
widget::tooltip::Position::Top,
)
.into(),
widget::tooltip(
button(icon_cache_get("replace-all-symbolic", 16))
.on_press(Message::FindReplaceAll)
.padding(space_xxs)
.style(style::Button::Icon),
fl!("replace-all"),
widget::tooltip::Position::Top,
)
.into(),
])
.align_items(Alignment::Center)
.padding(space_xxs)
.spacing(space_xxs);
column = column.push(replace_widget);
}
tab_column = tab_column.push(
2024-01-09 13:37:10 -07:00
widget::cosmic_container::container(column).layer(cosmic_theme::Layer::Primary),
);
}
2023-10-27 11:43:30 -06:00
let content: Element<_> = tab_column.into();
2023-02-09 15:13:38 -07:00
// Uncomment to debug layout:
2023-10-25 21:11:08 -06:00
//content.explain(cosmic::iced::Color::WHITE)
content
2023-02-07 13:00:49 -07:00
}
fn subscription(&self) -> subscription::Subscription<Message> {
2023-11-27 09:02:35 -07:00
struct WatcherSubscription;
struct ConfigSubscription;
struct ThemeSubscription;
2023-11-03 18:45:03 -06:00
subscription::Subscription::batch([
event::listen_with(|event, _status| match event {
2023-11-03 18:45:03 -06:00
event::Event::Keyboard(keyboard::Event::KeyPressed {
modifiers,
key_code,
}) => Some(Message::Key(modifiers, key_code)),
2024-01-11 12:45:13 -07:00
event::Event::Keyboard(keyboard::Event::ModifiersChanged(modifiers)) => {
Some(Message::Modifiers(modifiers))
}
2023-11-03 18:45:03 -06:00
_ => None,
}),
2023-11-27 09:02:35 -07:00
subscription::channel(
TypeId::of::<WatcherSubscription>(),
100,
|mut output| async move {
let watcher_res = {
let mut output = output.clone();
2023-12-01 13:19:56 -07:00
//TODO: debounce
2023-11-27 09:02:35 -07:00
notify::recommended_watcher(
move |event_res: Result<notify::Event, notify::Error>| match event_res {
Ok(event) => {
match &event.kind {
notify::EventKind::Access(_)
| notify::EventKind::Modify(
notify::event::ModifyKind::Metadata(_),
) => {
// Data not mutated
return;
}
_ => {}
2023-11-21 11:57:11 -07:00
}
2023-11-27 09:02:35 -07:00
match futures::executor::block_on(async {
output.send(Message::NotifyEvent(event)).await
}) {
Ok(()) => {}
Err(err) => {
log::warn!("failed to send notify event: {:?}", err);
}
2023-11-21 11:57:11 -07:00
}
}
2023-11-27 09:02:35 -07:00
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);
}
2023-11-21 11:57:11 -07:00
}
}
2023-11-27 09:02:35 -07:00
Err(err) => {
log::warn!("failed to create file watcher: {:?}", err);
}
2023-11-21 11:57:11 -07:00
}
2023-11-27 09:02:35 -07:00
//TODO: how to properly kill this task?
loop {
time::sleep(time::Duration::new(1, 0)).await;
2023-11-03 18:45:03 -06:00
}
},
),
2023-11-27 09:02:35 -07:00
cosmic_config::config_subscription(
TypeId::of::<ConfigSubscription>(),
Self::APP_ID.into(),
CONFIG_VERSION,
)
.map(|update| {
for error in update.errors {
log::error!("error loading config: {error:?}");
2023-11-27 09:02:35 -07:00
}
Message::Config(update.config)
2023-11-27 09:02:35 -07:00
}),
2023-11-14 12:09:26 -07:00
cosmic_config::config_subscription::<_, cosmic_theme::ThemeMode>(
2023-11-27 09:02:35 -07:00
TypeId::of::<ThemeSubscription>(),
2023-11-14 12:09:26 -07:00
cosmic_theme::THEME_MODE_ID.into(),
cosmic_theme::ThemeMode::version(),
)
.map(|update| {
for error in update.errors {
log::error!("error loading theme mode: {error:?}");
2023-11-14 12:09:26 -07:00
}
Message::SystemThemeModeChange(update.config)
2023-11-14 12:09:26 -07:00
}),
2023-11-03 18:45:03 -06:00
])
}
2023-02-07 13:00:49 -07:00
}