1mod draft;
21mod lineup;
22#[cfg(test)]
23mod tests;
24mod tried;
25
26use std::path::PathBuf;
27
28use qframe::diagnostics::Diagnostic;
29use qframe::prelude::*;
30use qframe::widgets::{EmptyState, Field, Modal, RadioGroup, TextInput};
31
32use crate::provider::{
33 Ask, AskError, CRAMPED, Key, Measured, Model, PermissionProblem, Price, ProviderEntry, ProviderKind,
34 Providers as ProviderFile, Reached, Web, Wire, ask, permission_problems,
35};
36use crate::store::Loaded;
37use crate::ui::page;
38
39pub use draft::{BASE_FIELD, Draft, KEY_FIELD, Problem, TAG_FIELD};
40pub use lineup::{Editor, Lineups, Refusal};
41
42use lineup::{FILTER_FIELD, LIST as LINEUPS, MODELS as LINEUP_MODELS, NAME_FIELD, STEPS as LINEUP_STEPS};
43use tried::{Reason, TRIED, Verdict};
44
45const DIALOG_WIDTH: u16 = 72;
49
50const LIST: &str = "providers";
52
53const MODELS: &str = "provider-models";
55
56const DIALOG_FRAME: u16 = 8;
62
63const ROWS_AROUND_MODELS: u16 = 13;
71
72const MODEL_ROWS_MIN: u16 = 2;
75
76const MODEL_ROWS_MAX: u16 = 12;
80
81const STEP_ROWS: u16 = 2;
85
86const STEP_ROWS_MAX: u16 = 8;
91
92fn rows_for_the_two_lists(ui: &View<'_, Msg>) -> u16 {
96 ui.size().height.saturating_sub(DIALOG_FRAME + ROWS_AROUND_MODELS)
97}
98
99fn model_rows(ui: &View<'_, Msg>) -> u16 {
103 rows_for_the_two_lists(ui).clamp(MODEL_ROWS_MIN, MODEL_ROWS_MAX)
104}
105
106fn step_rows(ui: &View<'_, Msg>) -> u16 {
111 let spare = rows_for_the_two_lists(ui);
112 (STEP_ROWS + spare.saturating_sub(model_rows(ui))).clamp(STEP_ROWS, STEP_ROWS_MAX)
113}
114
115#[derive(Debug, Clone, Copy, PartialEq, Eq)]
118pub enum Busy {
119 Trying,
121 Listing,
123 Measuring,
125}
126
127#[derive(Debug, Clone, PartialEq, Eq)]
130pub enum Notice {
131 Reached(Reached),
133 Trouble(AskError),
135 NotWritten(String),
137}
138
139#[derive(Debug, Clone)]
141pub enum Msg {
142 Reload,
144 Loaded(Box<Loaded<ProviderFile>>, Vec<PermissionProblem>),
146 Select(usize),
148 PickModel(usize),
150 New,
152 Cancel,
154 Tag(String),
156 Base(String),
158 PasteKey(String),
160 PickKind(usize),
162 PickRegion(usize),
164 Add,
166 DeleteAsked,
168 DeleteConfirmed,
170 ForgetKey,
172 Try,
174 Tried(Box<Result<Reached, AskError>>),
176 Refresh,
178 Listed(Box<Result<Vec<Model>, AskError>>),
180 MeasureAsked,
182 Measured(String, Box<Result<Measured, AskError>>),
184 LineupsAsked,
186 LineupsClosed,
188 LineupChosen(usize),
190 LineupNew,
192 LineupEditAsked,
194 LineupDeleteAsked,
196 LineupDeleteConfirmed,
198 LineupName(String),
200 LineupFilter(String),
202 LineupLooked(String),
204 LineupAdded(usize),
206 LineupStep(usize),
208 LineupUp,
210 LineupDown,
212 LineupRemove,
214 LineupSave,
216 LineupCancel,
218}
219
220#[derive(Debug, Clone)]
222pub struct Providers {
223 path: Option<PathBuf>,
225 file: ProviderFile,
226 problems: Vec<Diagnostic>,
227 permissions: Vec<PermissionProblem>,
228 selected: usize,
229 model: usize,
230 draft: Option<Draft>,
231 lineups: Option<Lineups>,
232 web: Web,
233 busy: Option<Busy>,
234 notice: Option<Notice>,
235 loading: bool,
236}
237
238impl Providers {
239 #[must_use]
244 pub fn new(path: Option<PathBuf>, web: Web) -> Self {
245 Self {
246 path,
247 file: ProviderFile::in_memory(),
248 problems: Vec::new(),
249 permissions: Vec::new(),
250 selected: 0,
251 model: 0,
252 draft: None,
253 lineups: None,
254 web,
255 busy: None,
256 notice: None,
257 loading: false,
258 }
259 }
260
261 #[must_use]
264 pub fn path(&self) -> Option<&std::path::Path> {
265 self.path.as_deref()
266 }
267
268 #[must_use]
270 pub fn entries(&self) -> &[ProviderEntry] {
271 self.file.entries()
272 }
273
274 #[must_use]
276 pub fn selected(&self) -> Option<&ProviderEntry> {
277 self.entries().get(self.selected)
278 }
279
280 #[must_use]
282 pub fn chosen_model(&self) -> Option<&Model> {
283 self.selected()?.models.get(self.model)
284 }
285
286 #[must_use]
288 pub fn problems(&self) -> &[Diagnostic] {
289 &self.problems
290 }
291
292 #[must_use]
295 pub fn permissions(&self) -> &[PermissionProblem] {
296 &self.permissions
297 }
298
299 #[must_use]
301 pub fn draft(&self) -> Option<&Draft> {
302 self.draft.as_ref()
303 }
304
305 #[must_use]
307 pub fn lineups(&self) -> Option<&Lineups> {
308 self.lineups.as_ref()
309 }
310
311 #[must_use]
313 pub fn busy(&self) -> Option<Busy> {
314 self.busy
315 }
316
317 #[must_use]
319 pub fn notice(&self) -> Option<&Notice> {
320 self.notice.as_ref()
321 }
322
323 fn save(&mut self) {
325 if let Err(reason) = self.file.save() {
326 self.notice = Some(Notice::NotWritten(reason));
327 }
328 if let Some(path) = &self.path {
331 self.permissions = permission_problems(path);
332 }
333 }
334}
335
336#[must_use]
339pub fn entry(state: &Providers) -> Option<&'static str> {
340 if state.loading {
341 None
342 } else if state.entries().is_empty() {
343 Some("providers-empty")
344 } else {
345 Some(LIST)
346 }
347}
348
349#[must_use]
351pub fn hints(icons: &qframe::icons::Icons) -> Vec<(String, String)> {
352 let move_keys = format!("{}{}", icons.glyph("arrow-up"), icons.glyph("arrow-down"));
353 vec![(move_keys, t!("hints.move")), (icons.glyph("enter").into_owned(), t!("hints.open"))]
354}
355
356pub fn update(state: &mut Providers, message: Msg) -> Command<Msg> {
358 match message {
359 Msg::Reload => {
360 state.loading = true;
361 let path = state.path.clone();
362 Command::perform(move || {
363 let (loaded, permissions) = match &path {
364 Some(path) => (ProviderFile::open(path), permission_problems(path)),
365 None => (Loaded { value: ProviderFile::in_memory(), diagnostics: Vec::new() }, Vec::new()),
366 };
367 Msg::Loaded(Box::new(loaded), permissions)
368 })
369 }
370 Msg::Loaded(loaded, permissions) => {
371 let Loaded { mut value, diagnostics } = *loaded;
372 state.permissions = permissions;
373 if let Some(path) = &state.path {
374 value = value.at(path);
375 }
376 state.file = value;
377 for mut entry in state.file.entries().to_vec() {
380 ordered(&mut entry);
381 let tag = entry.tag.as_str().to_owned();
382 state.file.replace(&tag, entry);
383 }
384 state.problems = diagnostics;
385 state.loading = false;
386 state.selected = state.selected.min(state.entries().len().saturating_sub(1));
387 state.model = 0;
388 match entry(state) {
389 Some(control) => Command::focus(control),
390 None => Command::none(),
391 }
392 }
393 Msg::Select(index) => {
394 if index < state.entries().len() && index != state.selected {
395 state.selected = index;
396 state.model = 0;
399 state.notice = None;
400 }
401 Command::none()
402 }
403 Msg::PickModel(index) => {
404 if index < state.selected().map_or(0, |entry| entry.models.len()) {
405 state.model = index;
406 }
407 Command::none()
408 }
409 Msg::New => {
410 state.draft = Some(Draft::new());
411 Command::focus(TAG_FIELD)
412 }
413 Msg::Cancel => {
414 state.draft = None;
416 match entry(state) {
417 Some(control) => Command::focus(control),
418 None => Command::none(),
419 }
420 }
421 Msg::Tag(text) => {
422 if let Some(draft) = &mut state.draft {
423 draft.tag = text;
424 draft.problem = None;
425 }
426 Command::none()
427 }
428 Msg::Base(text) => {
429 if let Some(draft) = &mut state.draft {
430 draft.base = text;
431 draft.problem = None;
432 }
433 Command::none()
434 }
435 Msg::PasteKey(text) => {
436 if let Some(draft) = &mut state.draft {
437 draft.key = text;
438 draft.problem = None;
439 }
440 Command::none()
441 }
442 Msg::PickKind(index) => {
443 if let (Some(draft), Some(kind)) = (&mut state.draft, ProviderKind::ALL.get(index).copied()) {
444 draft.pick_kind(kind);
445 }
446 Command::none()
447 }
448 Msg::PickRegion(index) => {
449 if let Some(draft) = &mut state.draft {
450 draft.pick_region(index);
451 }
452 Command::none()
453 }
454 Msg::Add => add(state),
455 Msg::DeleteAsked => match state.selected() {
456 Some(entry) => Command::confirm(
457 Confirm::new(t!("provider.delete-title", tag = entry.tag.as_str()), Msg::DeleteConfirmed)
458 .message(t!("provider.delete-message"))
459 .confirm_label(t!("provider.delete"))
460 .danger(),
461 ),
462 None => Command::none(),
463 },
464 Msg::DeleteConfirmed => {
465 let Some(tag) = state.selected().map(|entry| entry.tag.as_str().to_owned()) else {
466 return Command::none();
467 };
468 state.file.remove(&tag);
469 state.selected = state.selected.min(state.entries().len().saturating_sub(1));
470 state.model = 0;
471 state.notice = None;
472 state.save();
473 Command::none()
474 }
475 Msg::ForgetKey => {
476 let Some(tag) = state.selected().map(|entry| entry.tag.as_str().to_owned()) else {
477 return Command::none();
478 };
479 state.file.forget_key(&tag);
480 state.save();
481 Command::none()
482 }
483 Msg::Try => start(state, Busy::Trying, |web, entry| Msg::Tried(Box::new(ask::try_connection(&web, &entry)))),
484 Msg::Tried(answer) => {
485 state.busy = None;
486 state.notice = Some(match *answer {
487 Ok(reached) => Notice::Reached(reached),
488 Err(trouble) => Notice::Trouble(trouble),
489 });
490 Command::none()
491 }
492 Msg::Refresh => start(state, Busy::Listing, |web, entry| Msg::Listed(Box::new(ask::list_models(&web, &entry)))),
493 Msg::Listed(answer) => {
494 state.busy = None;
495 match *answer {
496 Ok(models) => {
497 let Some(mut entry) = state.selected().cloned() else { return Command::none() };
498 entry.models = models
501 .into_iter()
502 .map(|mut model| {
503 model.measured = entry.model(&model.id).and_then(|before| before.measured);
504 model
505 })
506 .collect();
507 ordered(&mut entry);
508 let tag = entry.tag.as_str().to_owned();
509 state.file.replace(&tag, entry);
510 state.model = 0;
511 state.notice = None;
512 state.save();
513 }
514 Err(trouble) => state.notice = Some(Notice::Trouble(trouble)),
515 }
516 Command::none()
517 }
518 Msg::MeasureAsked => {
519 let (Some(entry), Some(model)) = (state.selected().cloned(), state.chosen_model().cloned()) else {
520 return Command::none();
521 };
522 if state.busy.is_some() {
523 return Command::none();
524 }
525 state.busy = Some(Busy::Measuring);
526 state.notice = None;
527 let web = state.web.clone();
528 let id = model.id.clone();
529 Command::perform(move || {
530 let measured = ask::measure(&web, &entry, &id);
531 Msg::Measured(id, Box::new(measured))
532 })
533 }
534 Msg::Measured(id, answer) => {
535 state.busy = None;
536 match *answer {
537 Ok(measured) => {
538 let Some(mut entry) = state.selected().cloned() else { return Command::none() };
539 if let Some(model) = entry.models.iter_mut().find(|model| model.id == id) {
540 model.measured = Some(measured);
541 }
542 let tag = entry.tag.as_str().to_owned();
543 state.file.replace(&tag, entry);
544 state.save();
545 }
546 Err(trouble) => state.notice = Some(Notice::Trouble(trouble)),
547 }
548 Command::none()
549 }
550 Msg::LineupsAsked => {
551 let Some(entry) = state.selected() else { return Command::none() };
552 let mut dialog = Lineups::new();
553 dialog.open(entry);
554 state.lineups = Some(dialog);
555 Command::focus(LINEUPS)
556 }
557 Msg::LineupsClosed => {
558 state.lineups = None;
561 back(state)
562 }
563 Msg::LineupChosen(index) => {
564 let chosen =
565 state.selected().and_then(|entry| entry.lineups.get(index)).map(|l| l.name.as_str().to_owned());
566 if let (Some(name), Some(dialog)) = (chosen, state.lineups.as_mut()) {
567 dialog.choose(&name);
568 }
569 Command::none()
570 }
571 Msg::LineupNew => {
572 if let Some(dialog) = state.lineups.as_mut() {
573 dialog.new_lineup();
574 }
575 Command::focus(NAME_FIELD)
576 }
577 Msg::LineupEditAsked => {
578 let editing = state
581 .lineups
582 .as_ref()
583 .and_then(|dialog| dialog.chosen.clone())
584 .and_then(|name| state.selected().and_then(|entry| entry.lineup(&name)).cloned());
585 if let (Some(lineup), Some(dialog)) = (editing, state.lineups.as_mut()) {
586 dialog.edit(&lineup);
587 }
588 Command::focus(NAME_FIELD)
589 }
590 Msg::LineupDeleteAsked => {
591 let chosen = state.lineups.as_ref().and_then(|dialog| dialog.chosen.clone());
592 let Some(name) = chosen else { return Command::none() };
593 Command::confirm(
594 Confirm::new(t!("provider.lineup-delete-title", name = name.as_str()), Msg::LineupDeleteConfirmed)
595 .message(t!("provider.lineup-delete-message"))
596 .confirm_label(t!("provider.lineup-delete"))
597 .danger(),
598 )
599 }
600 Msg::LineupDeleteConfirmed => {
601 let chosen = state.lineups.as_ref().and_then(|dialog| dialog.chosen.clone());
602 let Some(name) = chosen else { return Command::none() };
603 let Some(tag) = state.selected().map(|entry| entry.tag.as_str().to_owned()) else {
604 return Command::none();
605 };
606 state.file.remove_lineup(&tag, &name);
607 state.save();
608 let left = lineup_names(&state.file, &tag);
611 if let Some(dialog) = state.lineups.as_mut() {
612 dialog.kept(&left);
613 }
614 Command::none()
615 }
616 Msg::LineupName(text) => {
617 if let Some(dialog) = state.lineups.as_mut() {
618 dialog.name(text);
619 }
620 Command::none()
621 }
622 Msg::LineupFilter(text) => {
623 if let Some(dialog) = state.lineups.as_mut() {
624 dialog.filter_for(text);
625 }
626 Command::none()
627 }
628 Msg::LineupLooked(id) => {
629 if let Some(dialog) = state.lineups.as_mut() {
630 dialog.look_at(&id);
631 }
632 Command::none()
633 }
634 Msg::LineupAdded(index) => {
635 let id = state
639 .lineups
640 .as_ref()
641 .zip(state.selected())
642 .and_then(|(dialog, entry)| dialog.shown(entry).get(index).map(|model| model.id.clone()));
643 if let (Some(id), Some(dialog)) = (id, state.lineups.as_mut()) {
644 dialog.add(&id);
645 }
646 Command::none()
647 }
648 Msg::LineupStep(index) => {
649 if let Some(dialog) = state.lineups.as_mut() {
650 dialog.pick_step(index);
651 }
652 Command::none()
653 }
654 Msg::LineupUp | Msg::LineupDown | Msg::LineupRemove => {
655 if let Some(dialog) = state.lineups.as_mut() {
656 match message {
657 Msg::LineupUp => dialog.up(),
658 Msg::LineupDown => dialog.down(),
659 _ => dialog.remove(),
660 }
661 }
662 Command::none()
663 }
664 Msg::LineupSave => save_lineup(state),
665 Msg::LineupCancel => {
666 if let Some(dialog) = state.lineups.as_mut() {
667 dialog.cancelled();
668 }
669 Command::none()
670 }
671 }
672}
673
674fn back(state: &Providers) -> Command<Msg> {
677 match entry(state) {
678 Some(control) => Command::focus(control),
679 None => Command::none(),
680 }
681}
682
683fn lineup_names(file: &ProviderFile, tag: &str) -> Vec<String> {
685 file.get(tag)
686 .map_or_else(Vec::new, |entry| entry.lineups.iter().map(|lineup| lineup.name.as_str().to_owned()).collect())
687}
688
689fn save_lineup(state: &mut Providers) -> Command<Msg> {
693 let (Some(tag), Some(dialog)) =
694 (state.selected().map(|entry| entry.tag.as_str().to_owned()), state.lineups.as_ref())
695 else {
696 return Command::none();
697 };
698 let file = &state.file;
699 let taken = |name: &str| file.get(&tag).and_then(|entry| entry.lineup(name)).is_some();
700 match dialog.build(taken) {
701 Ok(lineup) => {
702 let name = lineup.name.as_str().to_owned();
703 state.file.set_lineup(&tag, lineup);
704 state.save();
705 if let Some(dialog) = state.lineups.as_mut() {
706 dialog.saved(&name);
707 }
708 Command::focus(LINEUPS)
709 }
710 Err(refusal) => {
711 let beside_name = matches!(refusal, Refusal::Name(_) | Refusal::Taken(_));
714 if let Some(editor) = state.lineups.as_mut().and_then(|dialog| dialog.editor.as_mut()) {
715 editor.refusal = Some(refusal);
716 }
717 if beside_name { Command::focus(NAME_FIELD) } else { Command::none() }
718 }
719 }
720}
721
722fn add(state: &mut Providers) -> Command<Msg> {
724 let Some(draft) = state.draft.clone() else { return Command::none() };
725 let taken = |tag: &str| state.file.get(tag).is_some();
726 match draft.build(taken) {
727 Ok(entry) => {
728 let tag = entry.tag.as_str().to_owned();
729 if state.file.add(entry).is_err() {
732 return Command::none();
733 }
734 state.draft = None;
735 state.selected = state.entries().iter().position(|entry| entry.tag.as_str() == tag).unwrap_or(0);
736 state.model = 0;
737 state.notice = None;
738 state.save();
739 Command::focus(LIST)
740 }
741 Err(problem) => {
742 let field = problem.field();
743 if let Some(draft) = &mut state.draft {
744 draft.problem = Some(problem);
745 }
746 Command::focus(field)
747 }
748 }
749}
750
751fn start(
753 state: &mut Providers,
754 busy: Busy,
755 work: impl FnOnce(Web, ProviderEntry) -> Msg + Send + 'static,
756) -> Command<Msg> {
757 let Some(entry) = state.selected().cloned() else { return Command::none() };
758 if state.busy.is_some() {
759 return Command::none();
760 }
761 state.busy = Some(busy);
762 state.notice = None;
763 let web = state.web.clone();
764 Command::perform(move || work(web, entry))
765}
766
767pub fn view(state: &Providers, ui: &mut View<'_, Msg>) {
769 draw_list(state, ui);
770 if let Some(draft) = state.draft() {
771 draw_dialog(draft, ui);
772 }
773 if let (Some(dialog), Some(entry)) = (state.lineups(), state.selected()) {
774 draw_lineups(dialog, entry, ui);
775 }
776}
777
778fn draw_list(state: &Providers, ui: &mut View<'_, Msg>) {
780 page::column(ui, page::WIDTH, |ui| {
781 ui.column(|ui| {
782 ui.row(|ui| {
789 ui.add(Text::new(t!("provider.title")).bold());
790 if !state.entries().is_empty() {
791 ui.spacer();
792 ui.add(Button::new(t!("provider.new")).icon("add").on_press(Msg::New)).id("provider-new");
793 }
794 })
795 .fill_width();
796 ui.add(Text::new(key_file_line(state)).color("warning")).fill_width();
800 for problem in state.permissions() {
801 let (place, mode, wanted) = (
802 problem.place.display().to_string(),
803 format!("{:04o}", problem.mode),
804 format!("{:04o}", problem.wanted),
805 );
806 let said =
807 t!("provider.permissions", place = place.as_str(), mode = mode.as_str(), wanted = wanted.as_str());
808 ui.add(Text::new(said).color("warning")).fill_width();
809 }
810 for problem in state.problems() {
811 ui.add(Text::new(problem.to_string()).color("warning")).fill_width();
812 }
813 if state.loading {
814 ui.add(Text::new(t!("provider.reading")).role("secondary"));
815 return;
816 }
817 if state.entries().is_empty() {
818 ui.add(
819 EmptyState::new(t!("provider.empty-title"))
820 .icon("inbox")
821 .message(t!("provider.empty-message"))
822 .action(Button::new(t!("provider.new")).variant("primary").on_press(Msg::New)),
823 )
824 .id("providers-empty")
825 .fill();
826 return;
827 }
828 let items = state.entries().iter().map(|entry| {
829 ListItem::new(entry.tag.as_str().to_owned()).detail(format!(
830 "{} {}",
831 kind_word(entry.kind),
832 entry.base
833 ))
834 });
835 let list = List::new(items).label_first(true).selected(Some(state.selected)).on_select(Msg::Select);
836 ui.add(list.wrap(true)).id(LIST).fill_width();
837
838 if let Some(entry) = state.selected() {
839 draw_chosen(state, entry, ui);
840 }
841 })
842 .fill()
843 .gap(1);
844 });
845}
846
847fn draw_chosen(state: &Providers, entry: &ProviderEntry, ui: &mut View<'_, Msg>) {
849 let busy = state.busy();
850 ui.column(|ui| {
856 ui.add(Text::new(key_line(entry)).role("secondary")).fill_width();
857 ui.add(Text::new(lineups_line(entry)).role("secondary").no_wrap()).fill_width();
858 })
859 .fill_width()
860 .gap(0);
861 if !entry.kind.regions().is_empty() {
862 ui.column(|ui| draw_speaks(entry.kind, &entry.base, ui)).fill_width();
863 }
864
865 if entry.models.is_empty() {
866 ui.add(Text::new(t!("provider.no-models")).role("secondary")).fill_width();
867 } else {
868 let items = entry.models.iter().map(|model| model_row(model, entry.kind));
869 let list = List::new(items).selected(Some(state.model)).on_select(Msg::PickModel);
873 ui.add(list.wrap(true)).id(MODELS).fill();
874 }
875 if let Some(model) = state.chosen_model()
877 && let Some((text, tone)) = verdict_line(model, entry.kind)
878 {
879 ui.add(Text::new(text).color(tone)).fill_width();
880 }
881 if let Some(model) = state.chosen_model()
883 && let Some(source) = model.published_at(entry.kind)
884 {
885 ui.add(Text::new(t!("provider.published-at", model = model.id.as_str(), source = source)).role("secondary"))
886 .fill_width();
887 }
888 if let Some(model) = state.chosen_model()
891 && model.measured.is_some_and(Measured::is_cramped)
892 {
893 ui.add(Text::new(t!("provider.cramped", tokens = tokens(CRAMPED))).color("warning")).fill_width();
894 ui.add(Text::new(t!("provider.cramped-remedy", model = model.id.as_str())).role("secondary")).fill_width();
895 }
896 if let Some(notice) = state.notice() {
897 let (text, tone) = notice_line(notice, entry);
898 ui.add(Text::new(text).color(tone)).fill_width();
899 }
900
901 ui.add(Text::new(t!("provider.going-to", line = ask::trial(entry).line().as_str())).role("secondary")).fill_width();
903
904 ui.row(|ui| {
905 ui.add(pressable(t!("provider.try"), t!("provider.trying"), busy == Some(Busy::Trying), busy, Msg::Try))
906 .id("provider-try");
907 let refresh = pressable(
908 t!("provider.refresh"),
909 t!("provider.refreshing"),
910 busy == Some(Busy::Listing),
911 busy,
912 Msg::Refresh,
913 );
914 ui.add(refresh).id("provider-refresh");
915 if state.chosen_model().is_some() {
916 let measure = pressable(
917 t!("provider.measure"),
918 t!("provider.measuring"),
919 busy == Some(Busy::Measuring),
920 busy,
921 Msg::MeasureAsked,
922 );
923 ui.add(measure).id("provider-measure");
924 }
925 ui.add(Button::new(t!("provider.lineup-button")).on_press(Msg::LineupsAsked)).id("provider-lineups");
930 ui.spacer();
931 })
932 .fill_width()
933 .wrap(true)
934 .gap(1);
935 ui.row(|ui| {
936 if entry.key.is_some() {
937 ui.add(Button::new(t!("provider.forget-key")).on_press(Msg::ForgetKey)).id("provider-forget");
938 }
939 ui.add(Button::new(t!("provider.delete")).variant("danger").on_press(Msg::DeleteAsked)).id("provider-delete");
940 ui.spacer();
941 })
942 .fill_width()
943 .gap(1);
944}
945
946fn pressable(label: String, working: String, mine: bool, busy: Option<Busy>, message: Msg) -> Button<Msg> {
949 let button = Button::new(if mine { working } else { label });
950 if busy.is_some() { button } else { button.on_press(message) }
951}
952
953fn draw_dialog(draft: &Draft, ui: &mut View<'_, Msg>) {
955 let problem = draft.problem.as_ref();
956 let at = |field: &str| problem.filter(|problem| problem.field() == field).map(problem_message);
957 let chosen = ProviderKind::ALL.iter().position(|kind| *kind == draft.kind);
958 let dialog = Modal::new().title(t!("provider.new-title")).width(DIALOG_WIDTH).on_close(Msg::Cancel);
959 ui.add_with(dialog, |ui| {
960 ui.column(|ui| {
961 let kinds = RadioGroup::new(ProviderKind::ALL.map(kind_word)).selected(chosen).on_select(Msg::PickKind);
962 ui.add(kinds.wrap(true)).id("provider-kind");
963 if !draft.kind.regions().is_empty() {
964 draw_ready_made(draft, ui);
965 }
966 let tag_error = at(TAG_FIELD);
967 ui.add_with(
968 Field::new(t!("provider.tag")).hint(t!("provider.tag-hint")).error(tag_error.clone()).required(true),
969 |ui| {
970 ui.add(TextInput::new(draft.tag.clone()).invalid(tag_error.is_some()).on_change(Msg::Tag))
971 .id(TAG_FIELD)
972 .fill_width();
973 },
974 )
975 .fill_width();
976 let base_error = at(BASE_FIELD);
977 ui.add_with(
978 Field::new(t!("provider.base")).hint(t!("provider.base-hint")).error(base_error.clone()).required(true),
979 |ui| {
980 ui.add(
983 TextInput::new(draft.base.clone())
984 .select_all_on_focus()
985 .invalid(base_error.is_some())
986 .on_change(Msg::Base),
987 )
988 .id(BASE_FIELD)
989 .fill_width();
990 },
991 )
992 .fill_width();
993 if draft.kind.needs_key() {
994 let key_error = at(KEY_FIELD);
995 ui.add_with(
996 Field::new(t!("provider.key"))
997 .hint(t!("provider.key-hint"))
998 .error(key_error.clone())
999 .required(true),
1000 |ui| {
1001 ui.add(
1005 TextInput::new(draft.key.clone())
1006 .password(true)
1007 .invalid(key_error.is_some())
1008 .on_change(Msg::PasteKey),
1009 )
1010 .id(KEY_FIELD)
1011 .fill_width();
1012 },
1013 )
1014 .fill_width();
1015 }
1016 ui.row(|ui| {
1017 ui.spacer();
1018 ui.add(Button::new(t!("provider.cancel")).on_press(Msg::Cancel)).id("provider-cancel");
1019 ui.add(Button::new(t!("provider.add")).variant("primary").on_press(Msg::Add)).id("provider-add");
1020 })
1021 .fill_width()
1022 .gap(1);
1023 })
1024 .gap(1);
1025 });
1026}
1027
1028fn draw_lineups(dialog: &Lineups, entry: &ProviderEntry, ui: &mut View<'_, Msg>) {
1030 let title = t!("provider.lineup-title", tag = entry.tag.as_str());
1031 let mut modal = Modal::new().title(title).width(DIALOG_WIDTH).on_close(Msg::LineupsClosed);
1032 if dialog.editor.is_some() {
1033 modal = modal
1034 .action(Button::new(t!("provider.lineup-up")).on_press(Msg::LineupUp))
1035 .action(Button::new(t!("provider.lineup-down")).on_press(Msg::LineupDown))
1036 .action(Button::new(t!("provider.lineup-remove")).variant("danger").on_press(Msg::LineupRemove))
1037 .action(Button::new(t!("provider.lineup-cancel")).on_press(Msg::LineupCancel))
1038 .action(Button::new(t!("provider.lineup-save")).variant("primary").on_press(Msg::LineupSave));
1039 } else {
1040 modal = modal
1041 .action(Button::new(t!("provider.lineup-new")).on_press(Msg::LineupNew))
1042 .action(Button::new(t!("provider.lineup-edit")).on_press(Msg::LineupEditAsked))
1043 .action(Button::new(t!("provider.lineup-delete")).variant("danger").on_press(Msg::LineupDeleteAsked))
1044 .action(Button::new(t!("provider.lineup-close")).on_press(Msg::LineupsClosed));
1045 }
1046 ui.add_with(modal, |ui| {
1047 ui.column(|ui| {
1051 if let Some(editor) = &dialog.editor {
1052 draw_editor(dialog, editor, entry, ui);
1053 } else {
1054 draw_list_of_lineups(dialog, entry, ui);
1055 }
1056 })
1057 .fill_height()
1058 .gap(1);
1059 });
1060}
1061
1062fn draw_list_of_lineups(dialog: &Lineups, entry: &ProviderEntry, ui: &mut View<'_, Msg>) {
1064 ui.add(Text::new(t!("provider.lineup-lead")).role("secondary")).fill_width();
1065 if entry.lineups.is_empty() {
1066 ui.add(Text::new(t!("provider.lineup-empty")).role("secondary")).fill_width();
1067 return;
1068 }
1069 let items = entry.lineups.iter().map(|lineup| {
1070 let steps: Vec<&str> = lineup.models.iter().map(String::as_str).collect();
1071 ListItem::new(lineup.name.as_str().to_owned()).detail(steps.join(" → "))
1072 });
1073 let chosen = dialog.chosen.as_deref().and_then(|name| entry.lineups.iter().position(|l| l.name.as_str() == name));
1074 let list = List::new(items).selected(chosen).on_select(Msg::LineupChosen);
1075 ui.add(list.wrap(true)).id(LINEUPS).fill();
1076}
1077
1078fn draw_editor(dialog: &Lineups, editor: &Editor, entry: &ProviderEntry, ui: &mut View<'_, Msg>) {
1084 let refusal = editor.refusal.as_ref();
1085 let name_error = name_refusal(refusal);
1086 ui.add_with(
1087 Field::new(t!("provider.lineup-name"))
1088 .hint(t!("provider.lineup-name-hint"))
1089 .error(name_error.clone())
1090 .required(true),
1091 |ui| {
1092 ui.add(TextInput::new(editor.name.clone()).invalid(name_error.is_some()).on_change(Msg::LineupName))
1093 .id(NAME_FIELD)
1094 .fill_width();
1095 },
1096 )
1097 .fill_width();
1098 ui.add_with(Field::new(t!("provider.lineup-models")), |ui| {
1099 ui.add(
1100 TextInput::new(editor.filter.clone())
1101 .placeholder(t!("provider.lineup-filter"))
1102 .on_change(Msg::LineupFilter),
1103 )
1104 .id(FILTER_FIELD)
1105 .fill_width();
1106 })
1107 .fill_width();
1108
1109 let shown = dialog.shown(entry);
1110 let rows = model_rows(ui);
1111 let chosen = editor
1115 .model
1116 .as_deref()
1117 .and_then(|id| shown.iter().position(|model| model.id == id))
1118 .or((!shown.is_empty()).then_some(0));
1119 let ids: Vec<String> = shown.iter().map(|model| model.id.clone()).collect();
1123 let items =
1124 shown.iter().map(|model| ListItem::new(model.id.clone()).detail(price_words(model.price).unwrap_or_default()));
1125 let list = List::new(items)
1126 .empty_text(t!("provider.lineup-none-match"))
1127 .selected(chosen)
1128 .on_select(move |index| Msg::LineupLooked(ids.get(index).cloned().unwrap_or_default()))
1129 .on_activate(Msg::LineupAdded);
1130 ui.add(list.wrap(true)).id(LINEUP_MODELS).height(Length::Cells(rows));
1131
1132 let steps = dialog.steps();
1133 let step_height = step_rows(ui);
1134 let rows: Vec<ListItem> = steps
1135 .iter()
1136 .enumerate()
1137 .map(|(at, id)| {
1138 let price = entry.model(id).and_then(|model| price_words(model.price)).unwrap_or_default();
1139 ListItem::new(format!("{}. {id}", at + 1)).detail(price)
1140 })
1141 .collect();
1142 let step_list = List::new(rows)
1143 .empty_text(t!("provider.lineup-none-yet"))
1144 .selected((!steps.is_empty()).then(|| dialog.step().min(steps.len() - 1)))
1145 .on_select(Msg::LineupStep);
1146 ui.add(step_list.wrap(true)).id(LINEUP_STEPS).height(Length::Cells(step_height));
1147
1148 let paid = dialog.paid(entry);
1150 if !paid.is_empty() {
1151 ui.add(Text::new(t!("provider.lineup-paid", models = paid.join(", ").as_str())).color("warning")).fill_width();
1152 }
1153 if let Some(Refusal::Empty) = refusal {
1154 ui.add(Text::new(t!("provider.lineup-refused-empty")).color("warning")).fill_width();
1155 }
1156}
1157
1158fn lineups_line(entry: &ProviderEntry) -> String {
1160 if entry.lineups.is_empty() {
1161 return t!("provider.lineup-none");
1162 }
1163 let named: Vec<String> = entry
1164 .lineups
1165 .iter()
1166 .map(|lineup| {
1167 let steps: Vec<&str> = lineup.models.iter().map(String::as_str).collect();
1168 format!("{} ({})", lineup.name, steps.join(" → "))
1169 })
1170 .collect();
1171 t!("provider.lineup-summary", lineups = named.join(", ").as_str())
1172}
1173
1174fn price_words(price: Option<Price>) -> Option<String> {
1176 match price {
1177 Some(Price::Free) => Some(t!("provider.price-free")),
1178 Some(Price::Paid) => Some(t!("provider.price-paid")),
1179 None => None,
1180 }
1181}
1182
1183fn name_refusal(refusal: Option<&Refusal>) -> Option<String> {
1186 use crate::provider::TagError;
1187
1188 match refusal? {
1189 Refusal::Name(TagError::Empty) => Some(t!("provider.tag-empty")),
1190 Refusal::Name(TagError::TooLong { length }) => {
1191 Some(t!("provider.tag-long", length = i64::try_from(*length).unwrap_or(i64::MAX)))
1192 }
1193 Refusal::Name(TagError::BadStart { character }) => {
1194 Some(t!("provider.tag-start", character = character.to_string().as_str()))
1195 }
1196 Refusal::Name(TagError::Illegal { position, character }) => Some(t!(
1197 "provider.tag-character",
1198 character = character.to_string().as_str(),
1199 position = i64::try_from(*position).unwrap_or(i64::MAX)
1200 )),
1201 Refusal::Taken(name) => Some(t!("provider.lineup-name-taken", name = name.as_str())),
1202 Refusal::Empty => None,
1203 }
1204}
1205
1206fn draw_ready_made(draft: &Draft, ui: &mut View<'_, Msg>) {
1210 let regions = draft.kind.regions().iter().map(|region| region_word(region.id));
1211 let group = RadioGroup::new(regions).horizontal(true).selected(draft.region()).on_select(Msg::PickRegion);
1212 ui.add(group.wrap(true)).id("provider-region");
1213 ui.column(|ui| {
1215 draw_speaks(draft.kind, &draft.base, ui);
1216 let models: Vec<&str> = draft.kind.published().iter().map(|model| model.id).collect();
1217 ui.add(Text::new(t!("provider.ready-made-models", models = models.join(", ").as_str())).role("secondary"))
1218 .fill_width();
1219 })
1220 .fill_width();
1221}
1222
1223fn draw_speaks(kind: ProviderKind, base: &str, ui: &mut View<'_, Msg>) {
1227 let (header, _) = kind.key_header();
1228 let anthropic = kind.api_address(base, Wire::Anthropic, "");
1229 let openai = kind.api_address(base, Wire::OpenAi, "/v1");
1230 ui.add(Text::new(t!("provider.speaks-anthropic", address = anthropic.as_str())).role("secondary")).fill_width();
1231 ui.add(Text::new(t!("provider.speaks-openai", address = openai.as_str())).role("secondary")).fill_width();
1232 ui.add(Text::new(t!("provider.key-header", header = header)).role("secondary")).fill_width();
1233}
1234
1235fn region_word(id: &str) -> String {
1237 match id {
1238 "europe" => t!("provider.region-europe"),
1239 "singapore" => t!("provider.region-singapore"),
1240 "china" => t!("provider.region-china"),
1241 _ => t!("provider.region-overseas"),
1242 }
1243}
1244
1245fn key_file_line(state: &Providers) -> String {
1247 match &state.path {
1248 Some(path) => t!("provider.key-file", path = path.display().to_string().as_str()),
1249 None => t!("provider.key-file-nowhere"),
1250 }
1251}
1252
1253fn key_line(entry: &ProviderEntry) -> String {
1255 match entry.key.as_ref().map(Key::last_four) {
1256 Some(Some(tail)) => t!("provider.key-shown", tail = tail.as_str()),
1257 Some(None) => t!("provider.key-hidden"),
1258 None => t!("provider.key-none"),
1259 }
1260}
1261
1262fn ordered(entry: &mut ProviderEntry) {
1265 if entry.kind == ProviderKind::OpenRouter {
1266 TRIED.order(&mut entry.models);
1267 }
1268}
1269
1270fn verdict(model: &Model, kind: ProviderKind) -> Option<Verdict> {
1272 (kind == ProviderKind::OpenRouter).then(|| TRIED.verdict(&model.id)).flatten()
1273}
1274
1275fn model_row(model: &Model, kind: ProviderKind) -> ListItem {
1282 let row = ListItem::new(model.id.clone());
1283 match verdict(model, kind) {
1284 Some(Verdict::Works(_)) => row.icon("check", Some("success")).detail(about_model(model, kind)),
1285 Some(Verdict::Fails(harness, reason)) => {
1286 row.icon("warning", Some("warning")).faint(true).detail(fails(harness, reason))
1287 }
1288 None => row.detail(about_model(model, kind)),
1289 }
1290}
1291
1292fn about_model(model: &Model, kind: ProviderKind) -> String {
1294 match price_words(model.price) {
1295 Some(price) => format!("{} · {price}", windows(model, kind)),
1296 None => windows(model, kind),
1297 }
1298}
1299
1300fn fails(harness: crate::profile::HarnessKind, reason: Reason) -> String {
1302 let harness = harness.record().display_name;
1303 match reason {
1304 Reason::Empty => t!("provider.fails-empty", harness = harness),
1305 Reason::Tools => t!("provider.fails-tools", harness = harness),
1306 Reason::Half => t!("provider.fails-half", harness = harness),
1307 }
1308}
1309
1310fn verdict_line(model: &Model, kind: ProviderKind) -> Option<(String, &'static str)> {
1312 match verdict(model, kind)? {
1313 Verdict::Works(harnesses) => {
1314 let names: Vec<&str> = harnesses.iter().map(|harness| harness.record().display_name).collect();
1315 Some((t!("provider.tried", harnesses = names.join(", ").as_str()), "success"))
1316 }
1317 Verdict::Fails(harness, reason) => Some((fails(harness, reason), "warning")),
1318 }
1319}
1320
1321fn windows(model: &Model, kind: ProviderKind) -> String {
1324 let claimed = match model.claimed {
1325 Some(claimed) if model.published_at(kind).is_some() => {
1326 t!("provider.claims-published", claimed = tokens(claimed))
1327 }
1328 Some(claimed) => t!("provider.claims", claimed = tokens(claimed)),
1329 None => t!("provider.claims-unknown"),
1330 };
1331 let given = match model.measured {
1332 Some(Measured::About(number)) => t!("provider.gives-about", tokens = tokens(number)),
1333 Some(Measured::AtLeast(number)) => t!("provider.gives-at-least", tokens = tokens(number)),
1334 None => t!("provider.gives-unknown"),
1335 };
1336 format!("{claimed} · {given}")
1337}
1338
1339fn tokens(count: u64) -> String {
1341 count.to_string()
1342}
1343
1344fn notice_line(notice: &Notice, entry: &ProviderEntry) -> (String, &'static str) {
1346 match notice {
1347 Notice::Reached(Reached::Version(version)) => {
1348 (t!("provider.reached-version", tag = entry.tag.as_str(), version = version.as_str()), "success")
1349 }
1350 Notice::Reached(Reached::KeyAccepted) => (t!("provider.reached-key"), "success"),
1351 Notice::Trouble(AskError::Unreachable { url, reason }) => {
1352 (t!("provider.unreachable", url = url.as_str(), reason = reason.as_str()), "danger")
1353 }
1354 Notice::Trouble(AskError::Refused { url, status: 429, said }) => {
1358 (t!("provider.rate-limited", url = url.as_str(), said = said.as_str()), "warning")
1359 }
1360 Notice::Trouble(AskError::Refused { url, status, said }) => {
1361 (t!("provider.refused", url = url.as_str(), status = i64::from(*status), said = said.as_str()), "danger")
1362 }
1363 Notice::Trouble(AskError::Unreadable { url, wanted }) => {
1364 (t!("provider.unreadable", url = url.as_str(), wanted = wanted.as_str()), "danger")
1365 }
1366 Notice::NotWritten(reason) => (t!("provider.save-failed", reason = reason.as_str()), "danger"),
1367 }
1368}
1369
1370fn problem_message(problem: &Problem) -> String {
1372 use crate::provider::TagError;
1373
1374 match problem {
1375 Problem::Tag(TagError::Empty) => t!("provider.tag-empty"),
1376 Problem::Tag(TagError::TooLong { length }) => {
1377 t!("provider.tag-long", length = i64::try_from(*length).unwrap_or(i64::MAX))
1378 }
1379 Problem::Tag(TagError::BadStart { character }) => {
1380 t!("provider.tag-start", character = character.to_string().as_str())
1381 }
1382 Problem::Tag(TagError::Illegal { position, character }) => t!(
1383 "provider.tag-character",
1384 character = character.to_string().as_str(),
1385 position = i64::try_from(*position).unwrap_or(i64::MAX)
1386 ),
1387 Problem::Taken(tag) => t!("provider.tag-taken", tag = tag.as_str()),
1388 Problem::NoBase => t!("provider.base-empty"),
1389 Problem::BaseNotAnAddress(written) => t!("provider.base-not-an-address", written = written.as_str()),
1390 Problem::BaseUnusable(written) => t!("provider.base-unusable", written = written.as_str()),
1391 Problem::NoKey(ProviderKind::OpenRouter) => t!("provider.key-needed"),
1392 Problem::NoKey(kind) => t!("provider.key-needed-for", kind = kind_word(*kind).as_str()),
1393 }
1394}
1395
1396fn kind_word(kind: ProviderKind) -> String {
1399 match kind {
1400 ProviderKind::Ollama => "ollama".to_owned(),
1401 ProviderKind::OpenRouter => "OpenRouter".to_owned(),
1402 ProviderKind::MimoTokenPlan => "Xiaomi MiMo Token Plan".to_owned(),
1403 ProviderKind::KimiCode => "Kimi Code".to_owned(),
1404 }
1405}
1406
1407#[must_use]
1409pub fn trial_of(entry: &ProviderEntry) -> Ask {
1410 ask::trial(entry)
1411}