use std::{
cell::RefCell,
rc::Rc,
sync::{Arc, RwLock},
time::Duration,
};
use ratatui::{Frame, layout::Rect};
use tuicore::{
AnimationSettings, EventCtx, EventOutcome, EventRoute, Flex, FlexItem, FocusCtx, FocusId,
FocusTarget, LayoutCtx, LayoutProposal, LayoutResult, LayoutSizeHint, LifecycleCtx,
ListControl, PasswordInput, RenderCtx, TextInput, TickResult, Toggle, TuiEvent, TuiNode,
};
use crate::{
app_settings::AppSettings,
service::AppService,
speed_reader_settings::{
MAX_MARKDOWN_BLOCK_PAUSE_MS, MAX_SPEED_READER_WPM, MIN_SPEED_READER_WPM,
parse_markdown_block_pause, parse_speed_reader_wpm,
},
};
enum SettingChange {
JiraBaseUrl(String),
JiraEmail(String),
JiraApiToken(String),
JiraDefaultProject(String),
JiraDefaultBoard(String),
JiraCompanyManagedUrls(bool),
JiraStoryPointsFieldId(String),
BacklogUseJiraVelocity(bool),
BacklogJiraVelocitySprints(String),
BacklogFixedSprintCapacity(String),
BacklogUseAverageTicketSize(bool),
BacklogFixedTicketSize(String),
BacklogSprintTolerancePercent(String),
BacklogExcludedSprintNameFragments(String),
Wpm(String),
MarkdownBlockPause(String),
RecentTicketsLimit(String),
}
pub(crate) struct SettingsDialog {
root: Flex<()>,
changes: Rc<RefCell<Vec<SettingChange>>>,
settings: Arc<RwLock<AppSettings>>,
service: AppService,
}
impl SettingsDialog {
pub(crate) fn new(settings: Arc<RwLock<AppSettings>>, service: AppService) -> Self {
let values = settings.read().expect("settings lock poisoned").clone();
let changes = Rc::new(RefCell::new(Vec::new()));
let url_changes = Rc::clone(&changes);
let email_changes = Rc::clone(&changes);
let token_changes = Rc::clone(&changes);
let project_changes = Rc::clone(&changes);
let board_changes = Rc::clone(&changes);
let company_managed_urls_changes = Rc::clone(&changes);
let story_points_changes = Rc::clone(&changes);
let velocity_changes = Rc::clone(&changes);
let velocity_sprints_changes = Rc::clone(&changes);
let sprint_capacity_changes = Rc::clone(&changes);
let average_ticket_size_changes = Rc::clone(&changes);
let ticket_size_changes = Rc::clone(&changes);
let tolerance_changes = Rc::clone(&changes);
let wpm_changes = Rc::clone(&changes);
let delay_changes = Rc::clone(&changes);
let recent_tickets_limit_changes = Rc::clone(&changes);
let root = Flex::column()
.child(
"jira-base-url",
TextInput::new()
.value(values.jira_base_url.clone())
.panel("Jira URL")
.placeholder("https://example.atlassian.net")
.focused(true)
.on_edit_end(move |value| {
url_changes
.borrow_mut()
.push(SettingChange::JiraBaseUrl(value));
}),
FlexItem::fixed(3),
)
.child(
"jira-email",
TextInput::new()
.value(values.jira_email.clone())
.panel("Jira email")
.on_edit_end(move |value| {
email_changes
.borrow_mut()
.push(SettingChange::JiraEmail(value));
}),
FlexItem::fixed(3),
)
.child(
"jira-api-token",
PasswordInput::new()
.value(values.jira_api_token.clone())
.panel("Jira API token")
.on_edit_end(move |value| {
token_changes
.borrow_mut()
.push(SettingChange::JiraApiToken(value));
}),
FlexItem::fixed(3),
)
.child(
"jira-default-project",
TextInput::new()
.value(values.jira_default_project.clone())
.panel("Default Jira project")
.placeholder("FIN")
.on_edit_end(move |value| {
project_changes
.borrow_mut()
.push(SettingChange::JiraDefaultProject(value));
}),
FlexItem::fixed(3),
)
.child(
"jira-default-board",
TextInput::new()
.value(values.jira_default_board.clone())
.numbers_only(true)
.panel("Default Jira board ID")
.placeholder("Auto-select from project")
.on_edit_end(move |value| {
board_changes
.borrow_mut()
.push(SettingChange::JiraDefaultBoard(value));
}),
FlexItem::fixed(3),
)
.child(
"jira-company-managed-urls",
Toggle::new("Jira project is company-managed")
.checked(values.jira_company_managed_urls)
.on_change(move |value| {
company_managed_urls_changes
.borrow_mut()
.push(SettingChange::JiraCompanyManagedUrls(value));
}),
FlexItem::fixed(1),
)
.child(
"jira-story-points-field-id",
TextInput::new()
.value(values.jira_story_points_field_id.clone())
.panel("Jira story-points custom-field ID")
.placeholder("Custom field ID")
.on_edit_end(move |value| {
story_points_changes
.borrow_mut()
.push(SettingChange::JiraStoryPointsFieldId(value));
}),
FlexItem::fixed(3),
)
.child(
"backlog-use-jira-velocity",
Toggle::new("Backlog capacity: use Jira velocity")
.checked(values.backlog_runway.use_jira_velocity)
.on_change(move |value| {
velocity_changes
.borrow_mut()
.push(SettingChange::BacklogUseJiraVelocity(value));
}),
FlexItem::fixed(1),
)
.child(
"backlog-jira-velocity-sprints",
TextInput::new()
.value(values.backlog_runway.jira_velocity_sprints.to_string())
.numbers_only(true)
.panel("Jira velocity sprints to average")
.placeholder("4")
.on_edit_end(move |value| {
velocity_sprints_changes
.borrow_mut()
.push(SettingChange::BacklogJiraVelocitySprints(value));
}),
FlexItem::fixed(3),
)
.child(
"backlog-fixed-sprint-capacity",
TextInput::new()
.value(values.backlog_runway.fixed_sprint_capacity.to_string())
.panel("Backlog fixed / fallback sprint capacity")
.placeholder("20")
.on_edit_end(move |value| {
sprint_capacity_changes
.borrow_mut()
.push(SettingChange::BacklogFixedSprintCapacity(value));
}),
FlexItem::fixed(3),
)
.child(
"backlog-use-average-ticket-size",
Toggle::new("Backlog assumptions: average loaded estimates")
.checked(values.backlog_runway.use_average_ticket_size)
.on_change(move |value| {
average_ticket_size_changes
.borrow_mut()
.push(SettingChange::BacklogUseAverageTicketSize(value));
}),
FlexItem::fixed(1),
)
.child(
"backlog-fixed-ticket-size",
TextInput::new()
.value(values.backlog_runway.fixed_ticket_size.to_string())
.panel("Backlog fixed assumed ticket size")
.placeholder("3")
.on_edit_end(move |value| {
ticket_size_changes
.borrow_mut()
.push(SettingChange::BacklogFixedTicketSize(value));
}),
FlexItem::fixed(3),
)
.child(
"backlog-sprint-tolerance-percent",
TextInput::new()
.value(values.backlog_runway.sprint_tolerance_percent.to_string())
.numbers_only(true)
.panel("Sprint target tolerance (%)")
.placeholder("20")
.on_edit_end(move |value| {
tolerance_changes
.borrow_mut()
.push(SettingChange::BacklogSprintTolerancePercent(value));
}),
FlexItem::fixed(3),
)
.child(
"backlog-excluded-sprint-name-fragments",
SprintExclusionList::new(
values.excluded_sprint_name_fragments.clone(),
Rc::clone(&changes),
),
FlexItem::fixed(7),
)
.child(
"speed-reader-wpm",
TextInput::new()
.value(values.speed_reader.wpm.to_string())
.numbers_only(true)
.panel("Reader WPM")
.on_edit_end(move |value| {
wpm_changes.borrow_mut().push(SettingChange::Wpm(value));
}),
FlexItem::fixed(3),
)
.child(
"markdown-block-pause",
TextInput::new()
.value(values.block_delay().as_millis().to_string())
.numbers_only(true)
.panel("Reader block delay (ms)")
.on_edit_end(move |value| {
delay_changes
.borrow_mut()
.push(SettingChange::MarkdownBlockPause(value));
}),
FlexItem::fixed(3),
)
.child(
"recent-tickets-limit",
TextInput::new()
.value(values.recent_tickets_limit.to_string())
.numbers_only(true)
.panel("Recent tickets to remember")
.placeholder("15")
.on_edit_end(move |value| {
recent_tickets_limit_changes
.borrow_mut()
.push(SettingChange::RecentTicketsLimit(value));
}),
FlexItem::fixed(3),
);
Self {
root,
changes,
settings,
service,
}
}
fn apply_changes(&self, ctx: &mut EventCtx<()>) {
let mut settings = self
.settings
.read()
.expect("settings lock poisoned")
.clone();
let mut changed = false;
for change in self.changes.borrow_mut().drain(..) {
match change {
SettingChange::JiraBaseUrl(value) => {
let value = value.trim().trim_end_matches('/').to_owned();
if settings.jira_base_url != value {
settings.jira_base_url = value;
settings.invalidate_discovered_story_points();
}
changed = true;
}
SettingChange::JiraEmail(value) => {
settings.jira_email = value.trim().into();
changed = true;
}
SettingChange::JiraApiToken(value) => {
settings.jira_api_token = value.trim().into();
changed = true;
}
SettingChange::JiraDefaultProject(value) => {
settings.jira_default_project = value.trim().to_ascii_uppercase();
changed = true;
}
SettingChange::JiraDefaultBoard(value) => {
let value = value.trim().to_owned();
if settings.jira_default_board != value {
settings.jira_default_board = value;
settings.invalidate_discovered_story_points();
}
changed = true;
}
SettingChange::JiraCompanyManagedUrls(value) => {
settings.jira_company_managed_urls = value;
changed = true;
}
SettingChange::JiraStoryPointsFieldId(value) => {
settings.set_manual_story_points_field(value.trim().into());
changed = true;
}
SettingChange::BacklogUseJiraVelocity(value) => {
settings.backlog_runway.use_jira_velocity = value;
changed = true;
}
SettingChange::BacklogJiraVelocitySprints(value) => {
let Some(value) = value
.trim()
.parse::<usize>()
.ok()
.filter(|value| *value > 0)
else {
ctx.notify(tuicore::Notification::warning(
"Invalid velocity sprint count",
"Enter a whole number greater than zero.",
));
continue;
};
settings.backlog_runway.jira_velocity_sprints = value;
changed = true;
}
SettingChange::BacklogFixedSprintCapacity(value) => {
let Some(value) = parse_positive_number(&value) else {
ctx.notify(tuicore::Notification::warning(
"Invalid sprint capacity",
"Enter a positive number of story points.",
));
continue;
};
settings.backlog_runway.fixed_sprint_capacity = value;
changed = true;
}
SettingChange::BacklogUseAverageTicketSize(value) => {
settings.backlog_runway.use_average_ticket_size = value;
changed = true;
}
SettingChange::BacklogFixedTicketSize(value) => {
let Some(value) = parse_nonnegative_number(&value) else {
ctx.notify(tuicore::Notification::warning(
"Invalid assumed ticket size",
"Enter zero or a positive number of story points.",
));
continue;
};
settings.backlog_runway.fixed_ticket_size = value;
changed = true;
}
SettingChange::BacklogSprintTolerancePercent(value) => {
let Some(value) = value
.trim()
.parse::<u8>()
.ok()
.filter(|value| *value <= 100)
else {
ctx.notify(tuicore::Notification::warning(
"Invalid sprint tolerance",
"Enter a whole percentage from 0 to 100.",
));
continue;
};
settings.backlog_runway.sprint_tolerance_percent = value;
changed = true;
}
SettingChange::BacklogExcludedSprintNameFragments(value) => {
settings.excluded_sprint_name_fragments = sprint_name_fragments(&value);
changed = true;
}
SettingChange::Wpm(value) => {
let Some(wpm) = parse_speed_reader_wpm(&value) else {
ctx.notify(tuicore::Notification::warning(
"Invalid speed reader WPM",
format!(
"Enter a whole number from {MIN_SPEED_READER_WPM} to {MAX_SPEED_READER_WPM}."
),
));
continue;
};
settings.speed_reader.wpm = wpm;
changed = true;
}
SettingChange::MarkdownBlockPause(value) => {
let Some(markdown_block_pause) = parse_markdown_block_pause(&value) else {
ctx.notify(tuicore::Notification::warning(
"Invalid block delay",
format!(
"Enter a whole number from 0 to {MAX_MARKDOWN_BLOCK_PAUSE_MS} ms."
),
));
continue;
};
settings.speed_reader.markdown_block_pause = markdown_block_pause;
changed = true;
}
SettingChange::RecentTicketsLimit(value) => {
let Some(value) = value
.trim()
.parse::<usize>()
.ok()
.filter(|value| (1..=100).contains(value))
else {
ctx.notify(tuicore::Notification::warning(
"Invalid recent ticket limit",
"Enter a whole number from 1 to 100.",
));
continue;
};
settings.recent_tickets_limit = value;
changed = true;
}
}
}
if changed {
self.service.save_settings(settings);
}
}
}
#[derive(Clone)]
struct SprintExclusionRow {
id: u64,
fragment: String,
}
struct SprintExclusionList {
list: ListControl<SprintExclusionRow, u64>,
changes: Rc<RefCell<Vec<SettingChange>>>,
}
impl SprintExclusionList {
fn new(fragments: Vec<String>, changes: Rc<RefCell<Vec<SettingChange>>>) -> Self {
let rows = fragments
.into_iter()
.enumerate()
.map(|(index, fragment)| SprintExclusionRow {
id: u64::try_from(index).unwrap_or(u64::MAX),
fragment,
})
.collect::<Vec<_>>();
let mut next_id = u64::try_from(rows.len()).unwrap_or(u64::MAX);
let list = ListControl::list(
rows,
|row: &SprintExclusionRow| row.id,
|row: &SprintExclusionRow| row.fragment.clone(),
move |fragment, _| {
let row = SprintExclusionRow {
id: next_id,
fragment,
};
next_id = next_id.saturating_add(1);
row
},
)
.title("Excluded sprint name fragments")
.empty_message("No sprint names excluded.")
.max_rows(100)
.editable(
|row| vec![row.fragment.clone()],
|row, values| row.fragment.clone_from(&values[0]),
);
Self { list, changes }
}
fn sync(&mut self) {
if self.list.take_events().is_empty() {
return;
}
let fragments = self
.list
.items()
.iter()
.map(|row| row.fragment.trim())
.filter(|fragment| !fragment.is_empty())
.collect::<Vec<_>>()
.join(",");
self.changes
.borrow_mut()
.push(SettingChange::BacklogExcludedSprintNameFragments(fragments));
}
}
impl TuiNode for SprintExclusionList {
fn measure(&self, proposal: LayoutProposal) -> LayoutSizeHint {
self.list.measure(proposal)
}
fn layout(&mut self, area: Rect, ctx: &mut LayoutCtx) -> LayoutResult {
self.list.layout(area, ctx)
}
fn render<'a>(&'a self, frame: &mut Frame, area: Rect, ctx: &mut RenderCtx<'a>) {
self.list.render(frame, area, ctx);
}
fn event(&mut self, event: &TuiEvent, ctx: &mut EventCtx<()>) -> EventOutcome {
let outcome = self.list.event(event, ctx);
self.sync();
outcome
}
fn dispatch_event(
&mut self,
route: &EventRoute,
event: &TuiEvent,
ctx: &mut EventCtx<()>,
) -> EventOutcome {
let outcome = self.list.dispatch_event(route, event, ctx);
self.sync();
outcome
}
fn dispatch_focus(&mut self, target: &FocusTarget, focused: bool, ctx: &mut FocusCtx<()>) {
self.list.dispatch_focus(target, focused, ctx);
}
fn focus(&mut self, target: Option<&FocusId>, focused: bool, ctx: &mut FocusCtx<()>) {
self.list.focus(target, focused, ctx);
}
fn tick(&mut self, dt: Duration, settings: AnimationSettings) -> TickResult {
self.list.tick(dt, settings)
}
fn init(&mut self, ctx: &mut LifecycleCtx<()>) {
self.list.init(ctx);
}
fn mount(&mut self, ctx: &mut LifecycleCtx<()>) {
self.list.mount(ctx);
}
fn unmount(&mut self, ctx: &mut LifecycleCtx<()>) {
self.list.unmount(ctx);
}
fn destroy(&mut self, ctx: &mut LifecycleCtx<()>) {
self.list.destroy(ctx);
}
}
fn sprint_name_fragments(value: &str) -> Vec<String> {
value
.split(',')
.map(str::trim)
.filter(|fragment| !fragment.is_empty())
.fold(Vec::new(), |mut fragments, fragment| {
if !fragments
.iter()
.any(|existing: &String| existing.eq_ignore_ascii_case(fragment))
{
fragments.push(fragment.to_owned());
}
fragments
})
}
fn parse_positive_number(value: &str) -> Option<f64> {
parse_nonnegative_number(value).filter(|value| *value > 0.0)
}
fn parse_nonnegative_number(value: &str) -> Option<f64> {
value
.trim()
.parse::<f64>()
.ok()
.filter(|value| value.is_finite() && *value >= 0.0)
}
impl TuiNode for SettingsDialog {
fn measure(&self, proposal: LayoutProposal) -> LayoutSizeHint {
self.root.measure(proposal)
}
fn layout(&mut self, area: Rect, ctx: &mut LayoutCtx) -> LayoutResult {
self.root.layout(area, ctx)
}
fn render<'a>(&'a self, frame: &mut Frame, area: Rect, ctx: &mut RenderCtx<'a>) {
self.root.render(frame, area, ctx);
}
fn event(&mut self, event: &TuiEvent, ctx: &mut EventCtx<()>) -> EventOutcome {
let outcome = self.root.event(event, ctx);
self.apply_changes(ctx);
outcome
}
fn dispatch_event(
&mut self,
route: &EventRoute,
event: &TuiEvent,
ctx: &mut EventCtx<()>,
) -> EventOutcome {
let outcome = self.root.dispatch_event(route, event, ctx);
self.apply_changes(ctx);
outcome
}
fn dispatch_focus(&mut self, target: &FocusTarget, focused: bool, ctx: &mut FocusCtx<()>) {
self.root.dispatch_focus(target, focused, ctx);
}
fn focus(&mut self, target: Option<&FocusId>, focused: bool, ctx: &mut FocusCtx<()>) {
self.root.focus(target, focused, ctx);
}
fn tick(&mut self, dt: Duration, settings: AnimationSettings) -> TickResult {
self.root.tick(dt, settings)
}
fn init(&mut self, ctx: &mut LifecycleCtx<()>) {
self.root.init(ctx);
}
fn mount(&mut self, ctx: &mut LifecycleCtx<()>) {
self.root.mount(ctx);
}
fn unmount(&mut self, ctx: &mut LifecycleCtx<()>) {
self.root.unmount(ctx);
}
fn destroy(&mut self, ctx: &mut LifecycleCtx<()>) {
self.root.destroy(ctx);
}
}
#[cfg(test)]
mod tests;