284 lines
12 KiB
Rust
284 lines
12 KiB
Rust
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,
|
||
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()
|
||
}
|
||
}
|