Consolidate desktop controls with editable holidays and a running-window tray
This commit is contained in:
1 parent
9908dde9f8
commit
326465952a
55 files changed
+4207
-450
No files matched your search
@@ -0,0 +1,282 @@
|
||||
use super::{App, Element, Length, Message, column, container, row, schema, text_input, visuals};
|
||||
use super::{
|
||||
Task,
|
||||
store::{Snapshot, set},
|
||||
};
|
||||
use cosmic::widget::text_editor;
|
||||
use ferese_config::tr;
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct FunctionEditor {
|
||||
pub expanded: bool,
|
||||
initialized: bool,
|
||||
content: text_editor::Content,
|
||||
validating: bool,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use cosmic::Application;
|
||||
#[test]
|
||||
fn function_icon_preserves_unsaved_code_and_only_applies_matching_validation() {
|
||||
let mut app = App::init(
|
||||
cosmic::app::Core::default(),
|
||||
(
|
||||
"/unused/calendar.kdl".into(),
|
||||
Snapshot::parse(String::new()),
|
||||
None,
|
||||
),
|
||||
)
|
||||
.0;
|
||||
app.saving = true;
|
||||
let original = app.draft.source.clone();
|
||||
let _ = app.calendar_function_input(FunctionInput::Toggle);
|
||||
assert!(app.calendar_editor.expanded);
|
||||
assert!(!app.calendar_editor.dirty(&app.draft));
|
||||
let edited = "fn japanese_holidays(year) { #{} }";
|
||||
app.calendar_editor.content = text_editor::Content::with_text(edited);
|
||||
let _ = app.calendar_function_input(FunctionInput::Toggle);
|
||||
assert!(!app.calendar_editor.expanded);
|
||||
let _ = app.calendar_function_input(FunctionInput::Toggle);
|
||||
assert_eq!(app.calendar_editor.content.text(), edited);
|
||||
assert_eq!(app.draft.source, original);
|
||||
let _ = app.calendar_function_input(FunctionInput::Validated("stale".into(), Ok(())));
|
||||
assert!(app.pending.is_empty());
|
||||
let _ = app.calendar_function_input(FunctionInput::Validated(
|
||||
edited.into(),
|
||||
Err("invalid syntax".into()),
|
||||
));
|
||||
assert!(app.pending.is_empty() && app.calendar_editor.error.is_some());
|
||||
let _ = app.calendar_function_input(FunctionInput::Validated(edited.into(), Ok(())));
|
||||
assert_eq!(app.pending.len(), 1);
|
||||
assert_eq!(
|
||||
source(&Snapshot::parse(app.draft.source.clone()).unwrap()),
|
||||
edited
|
||||
);
|
||||
let _ = app.calendar_function_input(FunctionInput::Restore);
|
||||
assert_eq!(
|
||||
source(&app.draft),
|
||||
edited,
|
||||
"restoring the editor still requires Apply"
|
||||
);
|
||||
assert!(app.calendar_editor.dirty(&app.draft));
|
||||
}
|
||||
}
|
||||
|
||||
const FUNCTION_PATH: &str = "status.calendar.japanese_holiday_function";
|
||||
fn source(snapshot: &Snapshot) -> String {
|
||||
snapshot.string(
|
||||
FUNCTION_PATH,
|
||||
ferese_config::calendar::DEFAULT_JAPANESE_HOLIDAY_FUNCTION,
|
||||
)
|
||||
}
|
||||
|
||||
impl FunctionEditor {
|
||||
pub fn dirty(&self, snapshot: &Snapshot) -> bool {
|
||||
self.initialized && self.content.text() != source(snapshot)
|
||||
}
|
||||
pub fn refresh(&mut self, snapshot: &Snapshot) {
|
||||
if self.initialized {
|
||||
self.content = text_editor::Content::with_text(&source(snapshot));
|
||||
}
|
||||
self.validating = false;
|
||||
self.error = None;
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(super) enum FunctionInput {
|
||||
Toggle,
|
||||
Edit(text_editor::Action),
|
||||
Apply,
|
||||
Restore,
|
||||
Validated(String, Result<(), String>),
|
||||
}
|
||||
|
||||
impl App {
|
||||
pub(super) fn calendar_function_input(&mut self, input: FunctionInput) -> Task<Message> {
|
||||
match input {
|
||||
FunctionInput::Toggle => {
|
||||
if !self.calendar_editor.initialized || !self.calendar_editor.dirty(&self.draft) {
|
||||
self.calendar_editor.content =
|
||||
text_editor::Content::with_text(&source(&self.draft));
|
||||
self.calendar_editor.initialized = true;
|
||||
}
|
||||
self.calendar_editor.expanded = !self.calendar_editor.expanded;
|
||||
}
|
||||
FunctionInput::Edit(action) => {
|
||||
self.calendar_editor.content.perform(action);
|
||||
self.calendar_editor.error = None;
|
||||
}
|
||||
FunctionInput::Restore => {
|
||||
self.calendar_editor.content = text_editor::Content::with_text(
|
||||
ferese_config::calendar::DEFAULT_JAPANESE_HOLIDAY_FUNCTION,
|
||||
);
|
||||
self.calendar_editor.error = None;
|
||||
}
|
||||
FunctionInput::Apply if !self.saving && !self.calendar_editor.validating => {
|
||||
let source = self.calendar_editor.content.text();
|
||||
let edited = source.clone();
|
||||
self.calendar_editor.validating = true;
|
||||
return Task::perform(
|
||||
async move {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
ferese_config::calendar::validate_japanese_holiday_function(&source)
|
||||
})
|
||||
.await
|
||||
.unwrap_or_else(|error| Err(error.to_string()))
|
||||
},
|
||||
move |result| {
|
||||
cosmic::Action::App(Message::CalendarFunction(FunctionInput::Validated(
|
||||
edited.clone(),
|
||||
result,
|
||||
)))
|
||||
},
|
||||
);
|
||||
}
|
||||
FunctionInput::Validated(source, result) => {
|
||||
self.calendar_editor.validating = false;
|
||||
if source != self.calendar_editor.content.text() {
|
||||
return Task::none();
|
||||
}
|
||||
match result {
|
||||
Ok(()) => return self.change(set(FUNCTION_PATH, source)),
|
||||
Err(error) => {
|
||||
self.calendar_editor.error = Some(ferese_config::locale::translate(&error))
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
|
||||
fn calendar_function_view(&self) -> Element<'_, Message> {
|
||||
let state = &self.calendar_editor;
|
||||
let palette = self.native_palette;
|
||||
let mut rows = column([]).spacing(10).push(
|
||||
row([])
|
||||
.align_y(super::Alignment::Center)
|
||||
.push(
|
||||
self.panel_heading(tr!("Japanese holidays"), 14.)
|
||||
.width(Length::Fill),
|
||||
)
|
||||
.push(
|
||||
container(self.settings_icon_button(
|
||||
tr!("Edit holiday calculation function"),
|
||||
"M8 6l-6 6 6 6 M16 6l6 6-6 6 M14 4l-4 16",
|
||||
Some(Message::CalendarFunction(FunctionInput::Toggle)),
|
||||
))
|
||||
.id("calendar-function-toggle"),
|
||||
),
|
||||
);
|
||||
if state.expanded {
|
||||
rows=rows.push(self.note(tr!("Rhai function japanese_holidays(year) returns a YYYY-MM-DD to name map. Built-in rules cover 1955–2099; unpublished years are estimates.")))
|
||||
.push(cosmic::widget::TextEditor::new(&state.content)
|
||||
// The context-menu wrapper repeats its inner editor ID;
|
||||
// bypass it so named-state recovery retains the editor tree.
|
||||
.context_menu(false)
|
||||
.id("calendar-function-editor").font(self.font).size(11).height(280).padding(12)
|
||||
.style(move|_,status|text_editor::Style{
|
||||
background:palette.card.into(),
|
||||
border:cosmic::iced::Border{width:1.,radius:5.into(),color:if matches!(status,text_editor::Status::Focused{..}){palette.accent}else{palette.muted.scale_alpha(0.25)},..Default::default()},
|
||||
placeholder:palette.muted,value:palette.text,selection:palette.accent.scale_alpha(0.3),
|
||||
})
|
||||
.on_action(|action|Message::CalendarFunction(FunctionInput::Edit(action))))
|
||||
.push(row([]).spacing(10)
|
||||
.push(self.settings_button(if state.validating {tr!("Checking function…")} else {tr!("Apply function")},"M5 12l4 4L19 6",(!state.validating&&!self.saving&&state.dirty(&self.draft)).then_some(Message::CalendarFunction(FunctionInput::Apply)),false))
|
||||
.push(self.settings_button(tr!("Restore built-in function"),"M4 11a8 8 0 1 1 2 7 M4 4v7h7",Some(Message::CalendarFunction(FunctionInput::Restore)),false)));
|
||||
if let Some(error) = &state.error {
|
||||
rows = rows.push(self.note(&ferese_config::locale::format(
|
||||
"Holiday function error: {error}",
|
||||
&[("error", error.clone())],
|
||||
)));
|
||||
}
|
||||
}
|
||||
rows.into()
|
||||
}
|
||||
}
|
||||
|
||||
impl App {
|
||||
pub(super) fn calendar_controls(&self) -> Element<'_, Message> {
|
||||
let palette = self.native_palette;
|
||||
let date = self
|
||||
.inputs
|
||||
.get("$holiday.date")
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let name = self
|
||||
.inputs
|
||||
.get("$holiday.name")
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let valid = ferese_config::calendar::Holiday {
|
||||
date: date.trim().into(),
|
||||
name: name.trim().into(),
|
||||
}
|
||||
.validate()
|
||||
.is_ok();
|
||||
let mut rows = column([]).spacing(8);
|
||||
for index in 0..self.draft.records("status.calendar.custom_holidays") {
|
||||
let prefix = format!("status.calendar.custom_holidays.{index}");
|
||||
rows = rows.push(
|
||||
container(
|
||||
column([])
|
||||
.spacing(4)
|
||||
.push(self.field(schema::text(
|
||||
&format!("{prefix}.date"),
|
||||
"Date",
|
||||
"YYYY-MM-DD for one date; MM-DD repeats every year.",
|
||||
"",
|
||||
)))
|
||||
.push(self.field(schema::text(
|
||||
&format!("{prefix}.name"),
|
||||
"Holiday name",
|
||||
"",
|
||||
"",
|
||||
)))
|
||||
.push(self.settings_button(
|
||||
tr!("Remove holiday"),
|
||||
"M5 6h14 M9 6V3h6v3 M6 6l1 15h10l1-15",
|
||||
(!self.saving).then_some(Message::Remove(
|
||||
"status.calendar.custom_holidays".into(),
|
||||
index,
|
||||
)),
|
||||
false,
|
||||
)),
|
||||
)
|
||||
.padding(4)
|
||||
.class(visuals::section(palette)),
|
||||
);
|
||||
}
|
||||
let input = |placeholder, value, path: &'static str| {
|
||||
text_input(placeholder, value)
|
||||
.font(self.font)
|
||||
.size(12)
|
||||
.padding([7, 10])
|
||||
.style(visuals::input_style(palette))
|
||||
.on_input(move |value| Message::Draft(path.into(), value))
|
||||
.on_submit(|_| Message::AddHoliday)
|
||||
};
|
||||
rows = rows.push(
|
||||
row([])
|
||||
.spacing(10)
|
||||
.align_y(super::Alignment::Center)
|
||||
.push(input(tr!("YYYY-MM-DD / MM-DD"), date, "$holiday.date").width(170))
|
||||
.push(input(tr!("Holiday name"), name, "$holiday.name").width(Length::Fill))
|
||||
.push(self.settings_button(
|
||||
tr!("Add holiday"),
|
||||
"M12 5v14 M5 12h14",
|
||||
(!self.saving && valid).then_some(Message::AddHoliday),
|
||||
false,
|
||||
)),
|
||||
);
|
||||
column([]).spacing(10)
|
||||
.push(self.calendar_function_view())
|
||||
.push(self.panel_heading(tr!("Custom holidays"),14.))
|
||||
.push(self.note(tr!("Japanese public holidays are included. Add personal dates below; MM-DD repeats annually.")))
|
||||
.push(rows).into()
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user