Added Todo syntax highlighter

This commit is contained in:
2026-04-12 14:58:06 +10:00
committed by professor
parent 40d4ca363b
commit 84e7aa2a87
8 changed files with 891 additions and 512 deletions
Generated
+510 -492
View File
File diff suppressed because it is too large Load Diff
+4 -1
View File
@@ -7,7 +7,8 @@ description = "A COSMIC todo.txt application"
repository = "https://github.com/professorwaltwood/planit.git"
[dependencies]
cosmic-files = { git = "https://github.com/pop-os/cosmic-files.git", version = "1.0.8" }
cosmic-files = { git = "https://github.com/pop-os/cosmic-files", version = "1.0.8" }
# cosmic-files = { git = "https://github.com/pop-os/cosmic-files.git", version = "1.0.8" }
futures-util = "0.3.31"
i18n-embed = { version = "0.16", features = [
"fluent-system",
@@ -17,6 +18,7 @@ i18n-embed-fl = "0.10"
log = "0.4.29"
open = "5.3.2"
rust-embed = "8.8.0"
syntect = "5.3.0"
time = { version = "0.3.47", features = ["macros", "parsing"] }
tokio = { version = "1.48.0", features = ["full"] }
@@ -26,6 +28,7 @@ git = "https://github.com/pop-os/libcosmic.git"
features = [
# Accessibility support
#"a11y",
"debug",
# About widget for the app
"about",
# Uses cosmic-settings-daemon to watch for config file changes
+77
View File
@@ -0,0 +1,77 @@
%YAML 1.2
---
name: todo.txt
scope: text.todo
variables:
date: '(\d{4}-\d{2}-\d{2}(?:\s+|$))'
prio: '(\([A-Z]\)(?:\s+|$))'
contexts:
main:
- match: '^\s*x\s'
push: done
- match: ""
push: todo
done:
- meta_scope: comment.line
- include: check_prio
todo:
- meta_scope: text.todo
- include: check_prio
desc_fallback:
- match: '\s'
- match: ""
push: desc_start
check_prio:
- match: "{{date}}"
scope: constant.numeric
push: check_date2
- match: "{{prio}}"
scope: keyword
push: check_date
- include: desc_fallback
check_date:
- match: "{{date}}"
scope: constant.numeric
set: check_date2
- include: desc_fallback
check_date2:
- match: "{{date}}"
scope: constant.numeric
set: desc_start
- include: desc_fallback
# Any preceeding whitespace has been read
# safe to assume start of match is succeeds whitespace
desc_start:
- match: '@\S+'
scope: string
push: desc
- match: '\+\S+'
scope: variable.function
push: desc
- match: '\b([^\s:]+):([^\s:]+)\b'
captures:
1: entity.name
2: entity.name.tag
push: desc
- match: ""
set: desc
desc:
- match: '(?:\s|^)@\S+'
scope: string
- match: '(?:\s|^)\+\S+'
scope: variable.function
- match: '\b([^\s:]+):([^\s:]+)\b'
captures:
1: entity.name
2: entity.name.tag
+2
View File
@@ -18,3 +18,5 @@ test having lots of items +planit @tests
test having lots of items +planit @tests
test having lots of items +planit @tests
test having lots of items +planit @tests
+sda sda sda@ asd @Sasd @dsa a@adsa
2020-01-01 @asda +asd
+50 -18
View File
@@ -2,13 +2,16 @@
use crate::config::Config;
use crate::fl;
use crate::todo_editor::TodoHighlighter;
use cosmic::app::context_drawer;
use cosmic::cosmic_config::{self, CosmicConfigEntry};
use cosmic::cosmic_config::{self, ConfigSet, CosmicConfigEntry};
use cosmic::iced::alignment::{Horizontal, Vertical};
use cosmic::iced::futures::channel::mpsc::Sender;
use cosmic::iced::{Alignment, Length, Subscription};
use cosmic::iced::{Alignment, Length, Subscription, stream};
use cosmic::prelude::*;
use cosmic::widget::text_editor::{self, Content};
use cosmic::widget::{self, about::About, menu};
use cosmic::{Theme, iced_futures, prelude::*};
use futures_util::SinkExt;
use std::collections::HashMap;
use std::path;
@@ -33,6 +36,7 @@ pub struct AppModel {
/// Key bindings for the application's menu bar.
key_binds: HashMap<menu::KeyBind, MenuAction>,
/// Configuration data that persists between application runs.
config_handler: Option<cosmic_config::Config>,
config: Config,
/// Time active
time: u32,
@@ -41,6 +45,7 @@ pub struct AppModel {
/// Current Todo data
todo: Option<Todo>,
dialog_opt: Option<Dialog<Message>>,
content: Content,
}
/// Messages emitted by the application and its widgets.
@@ -53,6 +58,7 @@ pub enum Message {
OpenFileDialog,
DialogMessage(DialogMessage),
OpenFileResult(DialogResult),
Edit(text_editor::Action),
}
/// Create a COSMIC application from the app model
@@ -90,6 +96,26 @@ impl cosmic::Application for AppModel {
.links([(fl!("repository"), REPOSITORY)])
.license(env!("CARGO_PKG_LICENSE"));
let (config_handler, config) =
match cosmic_config::Config::new(Self::APP_ID, Config::VERSION) {
Ok(config_handler) => {
let config = match Config::get_entry(&config_handler) {
Ok(ok) => ok,
Err((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 todo = config.current_file.clone().map(|path| Todo::new(path));
// Construct the app model with the runtime's core.
let mut app = AppModel {
core,
@@ -97,22 +123,13 @@ impl cosmic::Application for AppModel {
about,
key_binds: HashMap::new(),
// Optional configuration file for an application.
config: cosmic_config::Config::new(Self::APP_ID, Config::VERSION)
.map(|context| match Config::get_entry(&context) {
Ok(config) => config,
Err((_errors, config)) => {
// for why in errors {
// tracing::error!(%why, "error loading app config");
// }
config
}
})
.unwrap_or_default(),
config_handler: config_handler,
config: config,
time: 0,
watch_is_active: false,
todo: None,
todo: todo,
dialog_opt: None,
content: Content::with_text("test"),
};
// Create a startup command that sets the window title.
@@ -167,6 +184,11 @@ impl cosmic::Application for AppModel {
let header = widget::row::with_capacity(2)
.push(widget::text::title1(fl!("welcome")))
.push(
widget::TextEditor::new(&self.content)
.on_action(Message::Edit)
.highlight_with::<TodoHighlighter>((), |hl, _t| hl.to_format()),
)
.align_y(Alignment::End)
.spacing(space_s);
@@ -222,7 +244,7 @@ impl cosmic::Application for AppModel {
// Conditionally enables a timer that emits a message every second.
if self.watch_is_active {
subscriptions.push(Subscription::run(|| {
iced_futures::stream::channel(1, |mut emitter: Sender<Message>| async move {
stream::channel(1, |mut emitter: Sender<Message>| async move {
let mut time = 1;
let mut interval = tokio::time::interval(Duration::from_secs(1));
@@ -295,7 +317,14 @@ impl cosmic::Application for AppModel {
}
},
};
let mut todo = Todo::new(cpath);
let mut todo = Todo::new(cpath.clone());
if let Some(cfg) = &self.config_handler {
if let Err(err) = cfg.set("current_file", Some(cpath)) {
log::error!("failed to save config {:?}", err);
}
}
// self.config.current_file = Some(cpath);
// Todo: this might need to be more async
if let Err(err) = todo.load() {
log::error!("failed to load file {:?} {:?}", path, err);
@@ -306,6 +335,9 @@ impl cosmic::Application for AppModel {
}
DialogResult::Cancel => todo!(),
},
Message::Edit(action) => {
self.content.perform(action);
}
}
Task::none()
}
+3 -1
View File
@@ -1,9 +1,11 @@
// SPDX-License-Identifier: MPL-2.0
use std::path::PathBuf;
use cosmic::cosmic_config::{self, CosmicConfigEntry, cosmic_config_derive::CosmicConfigEntry};
#[derive(Debug, Default, Clone, CosmicConfigEntry, Eq, PartialEq)]
#[version = 1]
pub struct Config {
demo: String,
pub current_file: Option<PathBuf>,
}
+1
View File
@@ -4,6 +4,7 @@ mod app;
mod config;
mod i18n;
mod todo;
mod todo_editor;
fn main() -> cosmic::iced::Result {
// Get the system's preferred languages.
+244
View File
@@ -0,0 +1,244 @@
use cosmic::{
iced::core::text::highlighter::{Format, Highlighter},
iced::{Color, Font},
theme::CosmicColor,
};
use std::{str::FromStr, sync::LazyLock};
use syntect::{highlighting, parsing};
static TODOSYNTAX: LazyLock<parsing::SyntaxSet> = LazyLock::new(|| {
let mut builder = parsing::SyntaxSetBuilder::new();
builder.add(
parsing::SyntaxDefinition::load_from_str(
std::include_str!("../resources/todo.sublime-syntax"),
false,
None,
)
.expect("Failed to load todo syntax"),
);
builder.build()
});
static TODOTHEME: LazyLock<highlighting::Theme> = LazyLock::new(|| get_current_theme());
pub struct TodoHighlighter {
syntax: &'static parsing::SyntaxReference,
highlighter: highlighting::Highlighter<'static>,
}
impl Highlighter for TodoHighlighter {
type Settings = ();
type Highlight = TodoHighlight;
type Iterator<'a> = Box<dyn Iterator<Item = (std::ops::Range<usize>, Self::Highlight)> + 'a>;
fn new(_settings: &Self::Settings) -> Self {
let syntax = TODOSYNTAX.syntaxes().get(0).expect("Todo Syntax Reference");
let highlighter = highlighting::Highlighter::new(&*TODOTHEME);
Self {
syntax,
highlighter,
}
}
fn update(&mut self, _s: &Self::Settings) {
// Re-highlight (only one line for planit)
self.change_line(0);
}
fn change_line(&mut self, line: usize) {
// planit only edits one todo at a time
assert_eq!(line, 0);
// Nothing to do as no point in caching one line
}
fn highlight_line(&mut self, line: &str) -> Self::Iterator<'_> {
let mut parser = parsing::ParseState::new(self.syntax);
let ops = parser.parse_line(line, &*TODOSYNTAX).unwrap_or_default();
Box::new(scope_iterator(ops, line, &self.highlighter))
}
fn current_line(&self) -> usize {
0 // only one line
}
}
pub struct TodoHighlight(highlighting::StyleModifier);
impl TodoHighlight {
pub fn color(&self) -> Option<Color> {
self.0.foreground.map(|color| Color {
r: color.r as f32 / 255.,
g: color.g as f32 / 255.,
b: color.b as f32 / 255.,
a: color.a as f32 / 255.,
})
}
pub fn font(&self) -> Option<Font> {
None
}
pub fn to_format(&self) -> Format<Font> {
Format {
color: self.color(),
font: self.font(),
}
}
}
fn scope_iterator<'a>(
ops: Vec<(usize, parsing::ScopeStackOp)>,
line: &str,
highlighter: &'a highlighting::Highlighter<'static>,
) -> impl Iterator<Item = (std::ops::Range<usize>, TodoHighlight)> + 'a {
let mut stack = parsing::ScopeStack::new();
ScopeRangeIterator {
ops,
line_length: line.len(),
index: 0,
last_str_index: 0,
}
.filter_map(move |(range, scope)| {
let _ = stack.apply(&scope);
if range.is_empty() {
None
} else {
Some((
range,
TodoHighlight(highlighter.style_mod_for_stack(&stack.scopes)),
))
}
})
}
struct ScopeRangeIterator {
ops: Vec<(usize, parsing::ScopeStackOp)>,
line_length: usize,
index: usize,
last_str_index: usize,
}
impl Iterator for ScopeRangeIterator {
type Item = (std::ops::Range<usize>, parsing::ScopeStackOp);
fn next(&mut self) -> Option<Self::Item> {
if self.index > self.ops.len() {
return None;
}
let next_str_i = if self.index == self.ops.len() {
self.line_length
} else {
self.ops[self.index].0
};
let range = self.last_str_index..next_str_i;
self.last_str_index = next_str_i;
let op = if self.index == 0 {
parsing::ScopeStackOp::Noop
} else {
self.ops[self.index - 1].1.clone()
};
self.index += 1;
Some((range, op))
}
}
fn get_current_theme() -> highlighting::Theme {
let theme = cosmic::theme::active();
let cosmic = theme.cosmic();
highlighting::Theme {
name: None,
author: None,
settings: highlighting::ThemeSettings::default(),
scopes: vec![
highlighting::ThemeItem {
scope: highlighting::ScopeSelectors::from_str("constant.numeric")
.expect("static scope selector"),
style: highlighting::StyleModifier {
foreground: Some(colour_map(cosmic.palette.accent_purple)),
background: None,
font_style: None,
},
},
highlighting::ThemeItem {
scope: highlighting::ScopeSelectors::from_str("comment.line")
.expect("static scope selector"),
style: highlighting::StyleModifier {
foreground: Some(colour_map(cosmic.palette.ext_warm_grey)),
background: None,
font_style: None,
},
},
highlighting::ThemeItem {
scope: highlighting::ScopeSelectors::from_str("text.todo string - comment.line")
.expect("static scope selector"),
style: highlighting::StyleModifier {
foreground: Some(colour_map(cosmic.palette.accent_green)),
background: None,
font_style: None,
},
},
highlighting::ThemeItem {
scope: highlighting::ScopeSelectors::from_str(
"text.todo variable.function - comment.line",
)
.expect("static scope selector"),
style: highlighting::StyleModifier {
foreground: Some(colour_map(cosmic.palette.accent_blue)),
background: None,
font_style: None,
},
},
highlighting::ThemeItem {
scope: highlighting::ScopeSelectors::from_str(
"text.todo entity.name - comment.line",
)
.expect("static scope selector"),
style: highlighting::StyleModifier {
foreground: Some(colour_map(cosmic.palette.accent_orange)),
background: None,
font_style: None,
},
},
highlighting::ThemeItem {
scope: highlighting::ScopeSelectors::from_str(
"text.todo entity.name.tag - comment.line",
)
.expect("static scope selector"),
style: highlighting::StyleModifier {
foreground: Some(colour_map(cosmic.palette.accent_orange)),
background: None,
font_style: None,
},
},
highlighting::ThemeItem {
scope: highlighting::ScopeSelectors::from_str("text.todo keyword - comment.line")
.expect("static scope selector"),
style: highlighting::StyleModifier {
foreground: Some(colour_map(cosmic.palette.accent_pink)),
background: None,
font_style: None,
},
},
],
}
}
fn colour_map(colour: CosmicColor) -> highlighting::Color {
highlighting::Color {
r: (colour.red * 255.) as u8,
g: (colour.green * 255.) as u8,
b: (colour.blue * 255.) as u8,
a: (colour.alpha * 255.) as u8,
}
}