max / audiofiles
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
8 files changed,
+1568 insertions,
-518 deletions
| @@ -28,6 +28,7 @@ | |||
| 28 | 28 | //! | [`Filters`] | [`filters`] | an [`Intent`], applied after the frame | | |
| 29 | 29 | //! | [`Storage`] | [`storage`] | an [`Intent`], applied after the frame | | |
| 30 | 30 | //! | [`Trash`] | [`trash`] | an [`Intent`], applied after the frame | | |
| 31 | + | //! | [`Classifier`] | [`classifier`] | an [`Intent`], applied after the frame | | |
| 31 | 32 | //! | [`ThemeChoice`] | [`settings`] | nothing: resolved once by the host | | |
| 32 | 33 | //! | |
| 33 | 34 | //! The themes are the settled rule from goingson's settings port applied first | |
| @@ -123,6 +124,7 @@ | |||
| 123 | 124 | #![allow(clippy::needless_pass_by_value)] | |
| 124 | 125 | ||
| 125 | 126 | pub mod bulk; | |
| 127 | + | pub mod classifier; | |
| 126 | 128 | pub mod detail; | |
| 127 | 129 | pub mod edit; | |
| 128 | 130 | pub mod export; | |
| @@ -923,6 +925,92 @@ | |||
| 923 | 925 | LocateLooseFiles, | |
| 924 | 926 | /// Bring a tombstoned sample back. | |
| 925 | 927 | RestoreSample(String), | |
| 928 | + | /// Begin authoring a rule. | |
| 929 | + | NewRule, | |
| 930 | + | /// Re-evaluate every rule across the library. | |
| 931 | + | ApplyRules, | |
| 932 | + | /// Add the filename starter rules, disabled. | |
| 933 | + | SeedRules, | |
| 934 | + | /// Turn one rule on or off, by id. | |
| 935 | + | EnableRule(String, bool), | |
| 936 | + | /// Open one rule in the editor, by id. | |
| 937 | + | EditRule(String), | |
| 938 | + | /// Delete one rule, by id. | |
| 939 | + | DeleteRule(String), | |
| 940 | + | /// Move one rule earlier (`true`) or later in evaluation order. | |
| 941 | + | MoveRule(String, bool), | |
| 942 | + | /// Name the rule being authored. | |
| 943 | + | NameDraft(String), | |
| 944 | + | /// Say whether it is enabled. | |
| 945 | + | EnableDraft(bool), | |
| 946 | + | /// Say whether every condition must hold, or any. | |
| 947 | + | MatchAll(bool), | |
| 948 | + | /// Add a condition to the draft. | |
| 949 | + | AddCondition, | |
| 950 | + | /// Take one away. | |
| 951 | + | DropCondition(usize), | |
| 952 | + | /// Set one part of one condition. | |
| 953 | + | SetCondition(usize, Part, String), | |
| 954 | + | /// Add an action to the draft. | |
| 955 | + | AddAction, | |
| 956 | + | /// Take one away. | |
| 957 | + | DropAction(usize), | |
| 958 | + | /// Set one part of one action. | |
| 959 | + | SetAction(usize, Part, String), | |
| 960 | + | /// Count what the draft would match. | |
| 961 | + | TestDraft, | |
| 962 | + | /// Save it. | |
| 963 | + | SaveDraft, | |
| 964 | + | /// Throw it away. | |
| 965 | + | CancelDraft, | |
| 966 | + | /// Suggest tags across the library. | |
| 967 | + | SuggestLibrary, | |
| 968 | + | /// Take back every tag one pass applied. | |
| 969 | + | UndoTagSource(String), | |
| 970 | + | /// Collect what auto-tagging would apply, writing nothing. | |
| 971 | + | ReviewLibrary, | |
| 972 | + | /// Go back to the queue the last collection left. | |
| 973 | + | ReopenReview, | |
| 974 | + | /// Distil the tagged library into a head. | |
| 975 | + | TrainHead, | |
| 976 | + | /// Throw the head away. | |
| 977 | + | ClearHead, | |
| 978 | + | /// Set one tag's two thresholds. | |
| 979 | + | SetPolicy(String, f64, f64), | |
| 980 | + | /// Remember what is being typed into the add-a-threshold box. | |
| 981 | + | TypingPolicy(String), | |
| 982 | + | /// Configure the tag that was typed. | |
| 983 | + | AddPolicy, | |
| 984 | + | /// Ask for this many groups next time. | |
| 985 | + | ClusterK(u32), | |
| 986 | + | /// Group the library. | |
| 987 | + | FindClusters, | |
| 988 | + | /// Preview a group's representative sample. | |
| 989 | + | PlayCluster(usize), | |
| 990 | + | /// Name a group. | |
| 991 | + | NameCluster(usize, String), | |
| 992 | + | /// Tag every sample in a group with its name. | |
| 993 | + | TagCluster(usize), | |
| 994 | + | /// Look for folders that directly hold samples. | |
| 995 | + | ScanFolders, | |
| 996 | + | /// Set the tag a folder would apply. | |
| 997 | + | NameFolder(usize, String), | |
| 998 | + | /// Apply it. | |
| 999 | + | ApplyFolder(usize), | |
| 1000 | + | /// Name the file an export would write. | |
| 1001 | + | NameExport(String), | |
| 1002 | + | /// Say whether an export carries one part. | |
| 1003 | + | Include(Shareable, bool), | |
| 1004 | + | /// Write the export. | |
| 1005 | + | ExportClassifier, | |
| 1006 | + | /// Read someone else's. | |
| 1007 | + | ImportClassifier, | |
| 1008 | + | /// Turn a layer on or off. | |
| 1009 | + | EnableLayer(String, bool), | |
| 1010 | + | /// Say how much a layer counts. | |
| 1011 | + | WeighLayer(String, f64), | |
| 1012 | + | /// Delete a layer and its imported rules. | |
| 1013 | + | RemoveLayer(String), | |
| 926 | 1014 | /// Remove a tombstoned sample now, skipping the retention window. | |
| 927 | 1015 | PurgeSample(String), | |
| 928 | 1016 | /// Open this library. | |
| @@ -4748,6 +4836,1086 @@ | |||
| 4748 | 4836 | /// of `Backend`, and this side reads the key it reads. | |
| 4749 | 4837 | const RETAIN_DAYS: i64 = 30; | |
| 4750 | 4838 | ||
| 4839 | + | /// The tag classifier, as much of it as its own screen needs. | |
| 4840 | + | /// | |
| 4841 | + | /// The fourteenth narrow trait and much the widest, because the subsystem is: | |
| 4842 | + | /// a rules editor, an auto-tagging pass with per-tag thresholds, an optional | |
| 4843 | + | /// trained head, a cluster browser, a folder-label pass and an import/export of | |
| 4844 | + | /// the whole thing. Every write is an intent, without exception — every | |
| 4845 | + | /// `classifier_*` method on `BrowserState` takes `&mut self`. | |
| 4846 | + | /// | |
| 4847 | + | /// # It says what a rule may test, rather than the description knowing | |
| 4848 | + | /// | |
| 4849 | + | /// [`testable`](Self::testable) is the unusual member. The obvious shape would | |
| 4850 | + | /// have been for the description to hold the field list and the operators each | |
| 4851 | + | /// field allows, the way `ui/classifier.rs` held `FIELDS`, `ops_for` and | |
| 4852 | + | /// `op_needs_value`. That would put "what a rule can ask about a sample" in the | |
| 4853 | + | /// description layer, where it is not: it is `audiofiles_core::rules`, and a | |
| 4854 | + | /// new `RuleField` would then have to be added in two places. | |
| 4855 | + | /// | |
| 4856 | + | /// So the app hands over the vocabulary and the description renders it. The | |
| 4857 | + | /// per-field operator set stops being a rule the screen re-implements and | |
| 4858 | + | /// becomes a described fact, which is also what lets the operator select narrow | |
| 4859 | + | /// when the field changes without the description knowing why. | |
| 4860 | + | pub trait Classifier { | |
| 4861 | + | /// What a job is doing, while one is running. | |
| 4862 | + | fn busy(&self) -> Option<String>; | |
| 4863 | + | ||
| 4864 | + | /// What the last job said went wrong. | |
| 4865 | + | fn failed(&self) -> Option<String>; | |
| 4866 | + | ||
| 4867 | + | /// Every rule, in evaluation order. | |
| 4868 | + | fn rules(&self) -> Vec<Ruling>; | |
| 4869 | + | ||
| 4870 | + | /// What a rule may test, and how. | |
| 4871 | + | fn testable(&self) -> Vec<Testable>; | |
| 4872 | + | ||
| 4873 | + | /// The rule being authored, if one is. | |
| 4874 | + | fn authoring(&self) -> Option<Authoring>; | |
| 4875 | + | ||
| 4876 | + | /// How many samples the last whole-library rule pass changed. | |
| 4877 | + | fn last_apply(&self) -> Option<usize>; | |
| 4878 | + | ||
| 4879 | + | /// How many tags the last auto-tagging pass applied. | |
| 4880 | + | fn last_suggest(&self) -> Option<usize>; | |
| 4881 | + | ||
| 4882 | + | /// What the last accepted review said. | |
| 4883 | + | fn last_review(&self) -> Option<String>; | |
| 4884 | + | ||
| 4885 | + | /// How many candidates are waiting in the review queue. | |
| 4886 | + | fn waiting(&self) -> usize; | |
| 4887 | + | ||
| 4888 | + | /// The trained head, if there is one. | |
| 4889 | + | fn head(&self) -> Option<Head>; | |
| 4890 | + | ||
| 4891 | + | /// Whether training is worth it yet, when there is no head. | |
| 4892 | + | fn readiness(&self) -> Option<Readiness>; | |
| 4893 | + | ||
| 4894 | + | /// The per-tag thresholds. | |
| 4895 | + | fn policies(&self) -> Vec<Policy>; | |
| 4896 | + | ||
| 4897 | + | /// The tag being typed into the add-a-threshold box. | |
| 4898 | + | fn policy_typing(&self) -> String; | |
| 4899 | + | ||
| 4900 | + | /// How many groups the next clustering run is asked for. | |
| 4901 | + | fn cluster_k(&self) -> u32; | |
| 4902 | + | ||
| 4903 | + | /// The groups the last run left, with the names typed against them. | |
| 4904 | + | fn clusters(&self) -> Vec<Pile>; | |
| 4905 | + | ||
| 4906 | + | /// The folders that directly hold samples, once they have been scanned. | |
| 4907 | + | /// | |
| 4908 | + | /// `None` before a scan, which is different from a scan that found none. | |
| 4909 | + | fn folders(&self) -> Option<Vec<Harvested>>; | |
| 4910 | + | ||
| 4911 | + | /// The export form as it stands. | |
| 4912 | + | fn sharing(&self) -> Sharing; | |
| 4913 | + | ||
| 4914 | + | /// The layers imported from `.afcl` files. | |
| 4915 | + | fn layers(&self) -> Vec<Layer>; | |
| 4916 | + | ||
| 4917 | + | /// Begin a new rule. | |
| 4918 | + | fn new_rule(&self); | |
| 4919 | + | ||
| 4920 | + | /// Re-evaluate every rule across the library. | |
| 4921 | + | fn apply_rules(&self); | |
| 4922 | + | ||
| 4923 | + | /// Add the filename starter rules, disabled. | |
| 4924 | + | fn seed_rules(&self); | |
| 4925 | + | ||
| 4926 | + | /// Turn one rule on or off. | |
| 4927 | + | fn enable_rule(&self, id: &str, on: bool); | |
| 4928 | + | ||
| 4929 | + | /// Open one rule in the editor. | |
| 4930 | + | fn edit_rule(&self, id: &str); | |
| 4931 | + | ||
| 4932 | + | /// Delete one rule. | |
| 4933 | + | fn delete_rule(&self, id: &str); | |
| 4934 | + | ||
| 4935 | + | /// Move one rule earlier or later in evaluation order. | |
| 4936 | + | fn move_rule(&self, id: &str, up: bool); | |
| 4937 | + | ||
| 4938 | + | /// Name the rule being authored. | |
| 4939 | + | fn name_draft(&self, name: &str); | |
| 4940 | + | ||
| 4941 | + | /// Say whether the rule being authored is enabled. | |
| 4942 | + | fn enable_draft(&self, on: bool); | |
| 4943 | + | ||
| 4944 | + | /// Say whether every condition must hold, or any. | |
| 4945 | + | fn match_all(&self, all: bool); | |
| 4946 | + | ||
| 4947 | + | /// Add a condition. | |
| 4948 | + | fn add_condition(&self); | |
| 4949 | + | ||
| 4950 | + | /// Take one away. | |
| 4951 | + | fn drop_condition(&self, at: usize); | |
| 4952 | + | ||
| 4953 | + | /// Set one part of one condition. | |
| 4954 | + | fn set_condition(&self, at: usize, part: Part, value: &str); | |
| 4955 | + | ||
| 4956 | + | /// Add an action. | |
| 4957 | + | fn add_action(&self); | |
| 4958 | + | ||
| 4959 | + | /// Take one away. | |
| 4960 | + | fn drop_action(&self, at: usize); | |
| 4961 | + | ||
| 4962 | + | /// Set one part of one action. | |
| 4963 | + | fn set_action(&self, at: usize, part: Part, value: &str); | |
| 4964 | + | ||
| 4965 | + | /// Count what the draft would match, without writing anything. | |
| 4966 | + | fn test_draft(&self); | |
| 4967 | + | ||
| 4968 | + | /// Save it. | |
| 4969 | + | fn save_draft(&self); | |
| 4970 | + | ||
| 4971 | + | /// Throw it away. | |
| 4972 | + | fn cancel_draft(&self); | |
| 4973 | + | ||
| 4974 | + | /// Suggest tags across the library and apply the confident ones. | |
| 4975 | + | fn suggest(&self); | |
| 4976 | + | ||
| 4977 | + | /// Take back every tag a given pass applied. | |
| 4978 | + | fn undo_source(&self, source: &str); | |
| 4979 | + | ||
| 4980 | + | /// Collect what auto-tagging would apply, writing nothing. | |
| 4981 | + | fn review(&self); | |
| 4982 | + | ||
| 4983 | + | /// Go back to the queue the last collection left. | |
| 4984 | + | fn reopen_review(&self); | |
| 4985 | + | ||
| 4986 | + | /// Distil the tagged library into a head. | |
| 4987 | + | fn train(&self); | |
| 4988 | + | ||
| 4989 | + | /// Throw the head away. | |
| 4990 | + | fn clear_head(&self); | |
| 4991 | + | ||
| 4992 | + | /// Set one tag's two thresholds. | |
| 4993 | + | fn set_policy(&self, tag: &str, review: f64, auto: f64); | |
| 4994 | + | ||
| 4995 | + | /// Remember what is being typed into the add-a-threshold box. | |
| 4996 | + | fn typing_policy(&self, tag: &str); | |
| 4997 | + | ||
| 4998 | + | /// Configure the tag that was typed. | |
| 4999 | + | fn add_policy(&self); | |
| 5000 | + | ||
| 5001 | + | /// Ask for this many groups next time. | |
| 5002 | + | fn set_cluster_k(&self, k: u32); | |
| 5003 | + | ||
| 5004 | + | /// Group the library. | |
| 5005 | + | fn find_clusters(&self); | |
| 5006 | + | ||
| 5007 | + | /// Preview a group's representative sample. | |
| 5008 | + | fn play_cluster(&self, at: usize); | |
| 5009 | + | ||
| 5010 | + | /// Name a group. | |
| 5011 | + | fn name_cluster(&self, at: usize, name: &str); | |
| 5012 | + | ||
| 5013 | + | /// Tag every sample in a group with its name. | |
| 5014 | + | fn tag_cluster(&self, at: usize); | |
| 5015 | + | ||
| 5016 | + | /// Look for folders that directly hold samples. | |
| 5017 | + | fn scan_folders(&self); | |
| 5018 | + | ||
| 5019 | + | /// Set the tag a folder would apply. | |
| 5020 | + | fn name_folder(&self, at: usize, tag: &str); | |
| 5021 | + | ||
| 5022 | + | /// Apply it. | |
| 5023 | + | fn apply_folder(&self, at: usize); | |
| 5024 | + | ||
| 5025 | + | /// Name the file an export would write. | |
| 5026 | + | fn name_export(&self, name: &str); | |
| 5027 | + | ||
| 5028 | + | /// Say whether an export carries this part. | |
| 5029 | + | fn include(&self, part: Shareable, on: bool); | |
| 5030 | + | ||
| 5031 | + | /// Write the export. | |
| 5032 | + | fn export(&self); | |
| 5033 | + | ||
| 5034 | + | /// Read someone else's. | |
| 5035 | + | fn import(&self); | |
| 5036 | + | ||
| 5037 | + | /// Turn a layer on or off. | |
| 5038 | + | fn enable_layer(&self, id: &str, on: bool); | |
| 5039 | + | ||
| 5040 | + | /// Say how much a layer counts. | |
| 5041 | + | fn weigh_layer(&self, id: &str, weight: f64); | |
| 5042 | + | ||
| 5043 | + | /// Delete a layer and its imported rules. | |
| 5044 | + | fn remove_layer(&self, id: &str); | |
| 5045 | + | } | |
| 5046 | + | ||
| 5047 | + | /// One rule, as the list names it. | |
| 5048 | + | #[derive(Debug, Clone, PartialEq, Eq)] | |
| 5049 | + | pub struct Ruling { | |
| 5050 | + | /// Its stored id, which is what every act carries. | |
| 5051 | + | pub id: String, | |
| 5052 | + | /// What it is called, empty when it was never named. | |
| 5053 | + | pub name: String, | |
| 5054 | + | /// Whether it runs. | |
| 5055 | + | pub enabled: bool, | |
| 5056 | + | /// How many conditions it tests. | |
| 5057 | + | pub conditions: usize, | |
| 5058 | + | /// How many actions it takes. | |
| 5059 | + | pub actions: usize, | |
| 5060 | + | /// Whether it is first in evaluation order. | |
| 5061 | + | pub first: bool, | |
| 5062 | + | /// Whether it is last. | |
| 5063 | + | pub last: bool, | |
| 5064 | + | } | |
| 5065 | + | ||
| 5066 | + | /// Something a rule can test, and the comparisons it allows. | |
| 5067 | + | #[derive(Debug, Clone, PartialEq, Eq)] | |
| 5068 | + | pub struct Testable { | |
| 5069 | + | /// The wire name, which is what a condition carries. | |
| 5070 | + | pub value: String, | |
| 5071 | + | /// What it is called. | |
| 5072 | + | pub label: String, | |
| 5073 | + | /// What may be asked of it. | |
| 5074 | + | pub ops: Vec<Comparison>, | |
| 5075 | + | } | |
| 5076 | + | ||
| 5077 | + | /// One comparison, and whether it wants an operand. | |
| 5078 | + | #[derive(Debug, Clone, PartialEq, Eq)] | |
| 5079 | + | pub struct Comparison { | |
| 5080 | + | /// The wire name. | |
| 5081 | + | pub value: String, | |
| 5082 | + | /// What it is called. | |
| 5083 | + | pub label: String, | |
| 5084 | + | /// Whether a value is compared against, or the operator is the whole test. | |
| 5085 | + | pub takes_value: bool, | |
| 5086 | + | } | |
| 5087 | + | ||
| 5088 | + | /// The rule being authored. | |
| 5089 | + | #[derive(Debug, Clone, PartialEq, Eq)] | |
| 5090 | + | pub struct Authoring { | |
| 5091 | + | /// What it is being called. | |
| 5092 | + | pub name: String, | |
| 5093 | + | /// Whether it will run once saved. | |
| 5094 | + | pub enabled: bool, | |
| 5095 | + | /// Whether every condition must hold, or any. | |
| 5096 | + | pub all: bool, | |
| 5097 | + | /// The conditions, in order. | |
| 5098 | + | pub conditions: Vec<Testing>, | |
| 5099 | + | /// The actions, in order. | |
| 5100 | + | pub actions: Vec<Doing>, | |
| 5101 | + | /// What a dry run counted, if one has been asked for. | |
| 5102 | + | pub matched: Option<usize>, | |
| 5103 | + | /// Whether a save has been refused, which is what gates the name's error. | |
| 5104 | + | pub refused: bool, | |
| 5105 | + | } | |
| 5106 | + | ||
| 5107 | + | /// One condition of the rule being authored. | |
| 5108 | + | #[derive(Debug, Clone, PartialEq, Eq)] | |
| 5109 | + | pub struct Testing { | |
| 5110 | + | /// Which testable, by wire name. | |
| 5111 | + | pub field: String, | |
| 5112 | + | /// Which comparison, by wire name. | |
| 5113 | + | pub op: String, | |
| 5114 | + | /// What it is compared against. | |
| 5115 | + | pub value: String, | |
| 5116 | + | } | |
| 5117 | + | ||
| 5118 | + | /// One action of the rule being authored. | |
| 5119 | + | #[derive(Debug, Clone, PartialEq, Eq)] | |
| 5120 | + | pub struct Doing { | |
| 5121 | + | /// `add`, `remove` or `stop`. | |
| 5122 | + | pub kind: String, | |
| 5123 | + | /// The tag, empty for `stop`. | |
| 5124 | + | pub tag: String, | |
| 5125 | + | } | |
| 5126 | + | ||
| 5127 | + | /// Which part of a condition or an action is being set. | |
| 5128 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 5129 | + | pub enum Part { | |
| 5130 | + | /// A condition's testable. | |
| 5131 | + | Field, | |
| 5132 | + | /// A condition's comparison. | |
| 5133 | + | Op, | |
| 5134 | + | /// A condition's operand, or an action's tag. | |
| 5135 | + | Value, | |
| 5136 | + | /// An action's kind. | |
| 5137 | + | Kind, | |
| 5138 | + | } | |
| 5139 | + | ||
| 5140 | + | /// The trained head that is in place. | |
| 5141 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 5142 | + | pub struct Head { | |
| 5143 | + | /// How many tags it knows. | |
| 5144 | + | pub classes: usize, | |
| 5145 | + | /// How many samples it was distilled from. | |
| 5146 | + | pub exemplars: usize, | |
| 5147 | + | } | |
| 5148 | + | ||
| 5149 | + | /// Whether training is worth doing yet. | |
| 5150 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 5151 | + | pub struct Readiness { | |
| 5152 | + | /// How many samples carry a tag. | |
| 5153 | + | pub tagged: usize, | |
| 5154 | + | /// Whether that is enough for a head to pay for itself. | |
| 5155 | + | pub worthwhile: bool, | |
| 5156 | + | } | |
| 5157 | + | ||
| 5158 | + | /// One tag's two thresholds. | |
| 5159 | + | #[derive(Debug, Clone, PartialEq)] | |
| 5160 | + | pub struct Policy { | |
| 5161 | + | /// The tag. | |
| 5162 | + | pub tag: String, | |
| 5163 | + | /// Above this, a suggestion is surfaced for review. | |
| 5164 | + | pub review: f64, | |
| 5165 | + | /// Above this, it is applied without asking. | |
| 5166 | + | pub auto: f64, | |
| 5167 | + | } | |
| 5168 | + | ||
| 5169 | + | /// One group the clustering pass left. | |
| 5170 | + | #[derive(Debug, Clone, PartialEq, Eq)] | |
| 5171 | + | pub struct Pile { | |
| 5172 | + | /// How many samples are in it. | |
| 5173 | + | pub members: usize, | |
| 5174 | + | /// The name typed against it, empty when it has none. | |
| 5175 | + | pub name: String, | |
| 5176 | + | /// Whether it has a representative sample left to preview. | |
| 5177 | + | /// | |
| 5178 | + | /// A pile outlives the sample at its centre: deleting that sample must not | |
| 5179 | + | /// take the pile or its name with it. | |
| 5180 | + | pub playable: bool, | |
| 5181 | + | } | |
| 5182 | + | ||
| 5183 | + | /// One folder that directly holds samples. | |
| 5184 | + | #[derive(Debug, Clone, PartialEq, Eq)] | |
| 5185 | + | pub struct Harvested { | |
| 5186 | + | /// The directory's own name. | |
| 5187 | + | pub folder: String, | |
| 5188 | + | /// How many samples sit directly in it. | |
| 5189 | + | pub samples: usize, | |
| 5190 | + | /// The tag it would apply, as suggested or as edited. | |
| 5191 | + | pub tag: String, | |
| 5192 | + | } | |
| 5193 | + | ||
| 5194 | + | /// The export form as it stands. | |
| 5195 | + | #[derive(Debug, Clone, PartialEq, Eq, Default)] | |
| 5196 | + | pub struct Sharing { | |
| 5197 | + | /// What the file will be called. | |
| 5198 | + | pub name: String, | |
| 5199 | + | /// Whether the feature vectors go in, and whether there are any. | |
| 5200 | + | pub exemplars: Included, | |
| 5201 | + | /// Whether the rules go in. | |
| 5202 | + | pub rules: Included, | |
| 5203 | + | /// Whether the thresholds go in. | |
| 5204 | + | pub thresholds: Included, | |
| 5205 | + | /// What the last export said. | |
| 5206 | + | pub exported: Option<String>, | |
| 5207 | + | /// What the last import said. | |
| 5208 | + | pub imported: Option<String>, | |
| 5209 | + | } | |
| 5210 | + | ||
| 5211 | + | impl Sharing { | |
| 5212 | + | /// Whether an export would carry anything at all. | |
| 5213 | + | /// | |
| 5214 | + | /// Asked of the three parts rather than of the toggles: a part that is | |
| 5215 | + | /// wanted and does not exist contributes nothing, which is the distinction | |
| 5216 | + | /// `export_has_content` drew and the reason [`Included`] has two fields. | |
| 5217 | + | #[must_use] | |
| 5218 | + | pub fn worth_writing(&self) -> bool { | |
| 5219 | + | [self.exemplars, self.rules, self.thresholds] | |
| 5220 | + | .iter() | |
| 5221 | + | .any(Included::counts) | |
| 5222 | + | } | |
| 5223 | + | } | |
| 5224 | + | ||
| 5225 | + | /// Whether a part of an export is wanted, and whether it exists. | |
| 5226 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] | |
| 5227 | + | pub struct Included { | |
| 5228 | + | /// Whether the reader asked for it. | |
| 5229 | + | pub wanted: bool, |
Lines truncated
| @@ -33,10 +33,10 @@ | |||
| 33 | 33 | use std::cell::RefCell; | |
| 34 | 34 | ||
| 35 | 35 | use super::{ | |
| 36 | - | FromBackend, FromBar, FromBulk, FromContents, FromEditor, FromExport, FromFilters, FromForge, | |
| 37 | - | FromImport, FromIntegrity, FromLibrary, FromNaming, FromQueue, FromSelection, FromStorage, | |
| 38 | - | FromSyncManager, FromTrash, FromWindow, Intent, Panels, Setting, Sync, ThemeChoice, | |
| 39 | - | Unconfigured, | |
| 36 | + | FromBackend, FromBar, FromBulk, FromClassifier, FromContents, FromEditor, FromExport, | |
| 37 | + | FromFilters, FromForge, FromImport, FromIntegrity, FromLibrary, FromNaming, FromQueue, | |
| 38 | + | FromSelection, FromStorage, FromSyncManager, FromTrash, FromWindow, Intent, Panels, Setting, | |
| 39 | + | Sync, ThemeChoice, Unconfigured, | |
| 40 | 40 | }; | |
| 41 | 41 | use crate::state::BrowserState; | |
| 42 | 42 | use crate::ui::theme; | |
| @@ -1537,6 +1537,168 @@ | |||
| 1537 | 1537 | // Both refresh the trash list themselves, so nothing here has to. | |
| 1538 | 1538 | Intent::RestoreSample(hash) => state.undelete_sample(&hash), | |
| 1539 | 1539 | Intent::PurgeSample(hash) => state.purge_sample(&hash), | |
| 1540 | + | // The classifier. Every arm is one `classifier_*` method, because | |
| 1541 | + | // every one of them is `&mut self` and several start work on | |
| 1542 | + | // another thread: there is no handle a route could have called. | |
| 1543 | + | Intent::NewRule => state.classifier_new_draft(), | |
| 1544 | + | Intent::ApplyRules => state.classifier_apply_all(), | |
| 1545 | + | Intent::SeedRules => state.classifier_seed_starter_rules(), | |
| 1546 | + | Intent::EnableRule(id, on) => state.classifier_toggle_rule(&id, on), | |
| 1547 | + | Intent::EditRule(id) => { | |
| 1548 | + | if let Some(rule) = state | |
| 1549 | + | .classifier | |
| 1550 | + | .rules | |
| 1551 | + | .iter() | |
| 1552 | + | .find(|rule| rule.id == id) | |
| 1553 | + | .cloned() | |
| 1554 | + | { | |
| 1555 | + | state.classifier_edit_rule(&rule); | |
| 1556 | + | } | |
| 1557 | + | } | |
| 1558 | + | Intent::DeleteRule(id) => state.classifier_delete_rule(&id), | |
| 1559 | + | // The store orders by priority and the mover takes a position, so | |
| 1560 | + | // the id is resolved here rather than in the route: a described | |
| 1561 | + | // screen naming a row by its id should not have to know that the | |
| 1562 | + | // app moves it by index. | |
| 1563 | + | Intent::MoveRule(id, up) => { | |
| 1564 | + | if let Some(at) = state.classifier.rules.iter().position(|rule| rule.id == id) { | |
| 1565 | + | state.classifier_move_rule(at, up); | |
| 1566 | + | } | |
| 1567 | + | } | |
| 1568 | + | Intent::NameDraft(name) => { | |
| 1569 | + | if let Some(draft) = state.classifier.editing.as_mut() { | |
| 1570 | + | draft.name = name; | |
| 1571 | + | // Typing into the name is what clears the refusal, exactly | |
| 1572 | + | // as the shipped field's `changed()` did. | |
| 1573 | + | draft.save_refused = false; | |
| 1574 | + | } | |
| 1575 | + | } | |
| 1576 | + | Intent::EnableDraft(on) => { | |
| 1577 | + | if let Some(draft) = state.classifier.editing.as_mut() { | |
| 1578 | + | draft.enabled = on; | |
| 1579 | + | } | |
| 1580 | + | } | |
| 1581 | + | Intent::MatchAll(all) => { | |
| 1582 | + | if let Some(draft) = state.classifier.editing.as_mut() { | |
| 1583 | + | draft.match_mode = if all { | |
| 1584 | + | audiofiles_core::rules::MatchMode::All | |
| 1585 | + | } else { | |
| 1586 | + | audiofiles_core::rules::MatchMode::Any | |
| 1587 | + | }; | |
| 1588 | + | } | |
| 1589 | + | } | |
| 1590 | + | Intent::AddCondition => { | |
| 1591 | + | if let Some(draft) = state.classifier.editing.as_mut() { | |
| 1592 | + | draft | |
| 1593 | + | .conditions | |
| 1594 | + | .push(audiofiles_core::rules::RuleCondition { | |
| 1595 | + | field: audiofiles_core::rules::RuleField::Name, | |
| 1596 | + | op: audiofiles_core::rules::RuleOp::Contains, | |
| 1597 | + | value: String::new(), | |
| 1598 | + | }); | |
| 1599 | + | draft.match_count = None; | |
| 1600 | + | } | |
| 1601 | + | } | |
| 1602 | + | Intent::DropCondition(at) => { | |
| 1603 | + | if let Some(draft) = state.classifier.editing.as_mut() | |
| 1604 | + | && at < draft.conditions.len() | |
| 1605 | + | && draft.conditions.len() > 1 | |
| 1606 | + | { | |
| 1607 | + | draft.conditions.remove(at); | |
| 1608 | + | draft.match_count = None; | |
| 1609 | + | } | |
| 1610 | + | } | |
| 1611 | + | Intent::SetCondition(at, part, value) => set_condition(state, at, part, &value), | |
| 1612 | + | Intent::AddAction => { | |
| 1613 | + | if let Some(draft) = state.classifier.editing.as_mut() { | |
| 1614 | + | draft | |
| 1615 | + | .actions | |
| 1616 | + | .push(audiofiles_core::rules::RuleAction::AddTag(String::new())); | |
| 1617 | + | } | |
| 1618 | + | } | |
| 1619 | + | Intent::DropAction(at) => { | |
| 1620 | + | if let Some(draft) = state.classifier.editing.as_mut() | |
| 1621 | + | && at < draft.actions.len() | |
| 1622 | + | { | |
| 1623 | + | draft.actions.remove(at); | |
| 1624 | + | } | |
| 1625 | + | } | |
| 1626 | + | Intent::SetAction(at, part, value) => set_action(state, at, part, &value), | |
| 1627 | + | Intent::TestDraft => state.classifier_test_draft(), | |
| 1628 | + | Intent::SaveDraft => state.classifier_save_draft(), | |
| 1629 | + | Intent::CancelDraft => state.classifier_cancel_draft(), | |
| 1630 | + | Intent::SuggestLibrary => state.classifier_auto_apply_ml(), | |
| 1631 | + | Intent::UndoTagSource(source) => state.undo_tag_source(&source), | |
| 1632 | + | Intent::ReviewLibrary => state.classifier_review_library(), | |
| 1633 | + | Intent::ReopenReview => state.open_review_screen(), | |
| 1634 | + | Intent::TrainHead => state.classifier_train_head(), | |
| 1635 | + | Intent::ClearHead => state.classifier_clear_head(), | |
| 1636 | + | Intent::SetPolicy(tag, review, auto) => { | |
| 1637 | + | state.classifier_set_policy(&tag, review, auto); | |
| 1638 | + | state.refresh_policies(); | |
| 1639 | + | } | |
| 1640 | + | Intent::TypingPolicy(tag) => state.classifier.new_policy_tag = tag, | |
| 1641 | + | Intent::AddPolicy => state.classifier_add_policy(), | |
| 1642 | + | Intent::ClusterK(k) => state.classifier.cluster_k = k, | |
| 1643 | + | Intent::FindClusters => state.run_clustering(), | |
| 1644 | + | Intent::PlayCluster(at) => { | |
| 1645 | + | if let Some(hash) = state | |
| 1646 | + | .classifier | |
| 1647 | + | .clusters | |
| 1648 | + | .get(at) | |
| 1649 | + | .and_then(|cluster| cluster.medoid_hash.clone()) | |
| 1650 | + | { | |
| 1651 | + | state.trigger_preview(&hash); | |
| 1652 | + | } | |
| 1653 | + | } | |
| 1654 | + | // Committed as it is typed, which is the one behaviour difference | |
| 1655 | + | // in this port. The shipped field wrote through on blur, and a | |
| 1656 | + | // described screen has no blur to write on; the alternative was a | |
| 1657 | + | // submit per pile. The cost is an UPDATE per keystroke against a | |
| 1658 | + | // local table, and the gain is a name that cannot be lost. | |
| 1659 | + | Intent::NameCluster(at, name) => { | |
| 1660 | + | if let Some(held) = state.classifier.cluster_names.get_mut(at) { | |
| 1661 | + | *held = name; | |
| 1662 | + | state.commit_cluster_name(at); | |
| 1663 | + | } | |
| 1664 | + | } | |
| 1665 | + | Intent::TagCluster(at) => state.apply_cluster(at), | |
| 1666 | + | Intent::ScanFolders => state.refresh_folder_labels(), | |
| 1667 | + | Intent::NameFolder(at, tag) => { | |
| 1668 | + | if let Some(held) = state.classifier.folder_tags.get_mut(at) { | |
| 1669 | + | *held = tag; | |
| 1670 | + | } | |
| 1671 | + | } | |
| 1672 | + | Intent::ApplyFolder(at) => state.apply_folder_label(at), | |
| 1673 | + | Intent::NameExport(name) => state.classifier.export_name = name, | |
| 1674 | + | Intent::Include(part, on) => match part { | |
| 1675 | + | super::Shareable::Exemplars => state.classifier.export_include_exemplars = on, | |
| 1676 | + | super::Shareable::Rules => state.classifier.export_include_rules = on, | |
| 1677 | + | super::Shareable::Thresholds => state.classifier.export_include_policy = on, | |
| 1678 | + | }, | |
| 1679 | + | // The host act with no described step, for the reason the module | |
| 1680 | + | // header gives: the export is built on a worker and answers through | |
| 1681 | + | // `BackendEvent`, so there are no bytes to hand back when the route | |
| 1682 | + | // answers. | |
| 1683 | + | Intent::ExportClassifier => { | |
| 1684 | + | let file = format!("{}.afcl", export_filename(&state.classifier.export_name)); | |
| 1685 | + | state.dialogs.save_file( | |
| 1686 | + | "Export classifier", | |
| 1687 | + | file, | |
| 1688 | + | &[("AF classifier", &["afcl"])], | |
| 1689 | + | |state, path| state.classifier_export_afcl(&path), | |
| 1690 | + | ); | |
| 1691 | + | } | |
| 1692 | + | Intent::ImportClassifier => { | |
| 1693 | + | state.dialogs.pick_file( | |
| 1694 | + | "Import classifier", | |
| 1695 | + | &[("AF classifier", &["afcl"])], | |
| 1696 | + | |state, path| state.classifier_import_afcl(&path), | |
| 1697 | + | ); | |
| 1698 | + | } | |
| 1699 | + | Intent::EnableLayer(id, on) => state.classifier_set_layer_enabled(&id, on), | |
| 1700 | + | Intent::WeighLayer(id, weight) => state.classifier_set_layer_weight(&id, weight), | |
| 1701 | + | Intent::RemoveLayer(id) => state.classifier_remove_layer(&id), | |
| 1540 | 1702 | } | |
| 1541 | 1703 | } | |
| 1542 | 1704 | } | |
| @@ -1570,6 +1732,96 @@ | |||
| 1570 | 1732 | state.described.settings = None; | |
| 1571 | 1733 | } | |
| 1572 | 1734 | ||
| 1735 | + | /// Set one part of one condition of the rule being authored. | |
| 1736 | + | /// | |
| 1737 | + | /// The coercion is here rather than in the route because it is the app's rule: | |
| 1738 | + | /// changing the field can strand the operator, and `coerce` is what the shipped | |
| 1739 | + | /// editor did on the way past every frame. | |
| 1740 | + | fn set_condition(state: &mut BrowserState, at: usize, part: super::Part, value: &str) { | |
| 1741 | + | let Some(draft) = state.classifier.editing.as_mut() else { | |
| 1742 | + | return; | |
| 1743 | + | }; | |
| 1744 | + | let Some(condition) = draft.conditions.get_mut(at) else { | |
| 1745 | + | return; | |
| 1746 | + | }; | |
| 1747 | + | match part { | |
| 1748 | + | super::Part::Field => { | |
| 1749 | + | let Some(field) = super::field_from(value) else { | |
| 1750 | + | return; | |
| 1751 | + | }; | |
| 1752 | + | condition.field = field; | |
| 1753 | + | condition.op = super::coerce(field, condition.op); | |
| 1754 | + | } | |
| 1755 | + | super::Part::Op => { | |
| 1756 | + | let Some(op) = super::op_from(condition.field, value) else { | |
| 1757 | + | return; | |
| 1758 | + | }; | |
| 1759 | + | condition.op = op; | |
| 1760 | + | } | |
| 1761 | + | super::Part::Value => value.clone_into(&mut condition.value), | |
| 1762 | + | super::Part::Kind => return, | |
| 1763 | + | } | |
| 1764 | + | // Any edit invalidates the dry run, which is what the shipped editor did | |
| 1765 | + | // when a condition was added or removed and did not do when one was | |
| 1766 | + | // retyped. The count is about the conditions as they stand. | |
| 1767 | + | draft.match_count = None; | |
| 1768 | + | } | |
| 1769 | + | ||
| 1770 | + | /// Set one part of one action of the rule being authored. | |
| 1771 | + | fn set_action(state: &mut BrowserState, at: usize, part: super::Part, value: &str) { | |
| 1772 | + | use audiofiles_core::rules::RuleAction::{AddTag, RemoveTag, Stop}; | |
| 1773 | + | let Some(draft) = state.classifier.editing.as_mut() else { | |
| 1774 | + | return; | |
| 1775 | + | }; | |
| 1776 | + | let Some(action) = draft.actions.get_mut(at) else { | |
| 1777 | + | return; | |
| 1778 | + | }; | |
| 1779 | + | match part { | |
| 1780 | + | super::Part::Kind => { | |
| 1781 | + | // The tag survives a change of kind, which is what the shipped | |
| 1782 | + | // editor carried across its three-way combo. | |
| 1783 | + | let tag = match action { | |
| 1784 | + | AddTag(tag) | RemoveTag(tag) => tag.clone(), | |
| 1785 | + | Stop => String::new(), | |
| 1786 | + | }; | |
| 1787 | + | *action = match value { | |
| 1788 | + | "add" => AddTag(tag), | |
| 1789 | + | "remove" => RemoveTag(tag), | |
| 1790 | + | "stop" => Stop, | |
| 1791 | + | _ => return, | |
| 1792 | + | }; | |
| 1793 | + | } | |
| 1794 | + | super::Part::Value => match action { | |
| 1795 | + | AddTag(tag) | RemoveTag(tag) => value.clone_into(tag), | |
| 1796 | + | Stop => {} | |
| 1797 | + | }, | |
| 1798 | + | super::Part::Field | super::Part::Op => {} | |
| 1799 | + | } | |
| 1800 | + | } | |
| 1801 | + | ||
| 1802 | + | /// A filename an export can be written under. | |
| 1803 | + | /// | |
| 1804 | + | /// Lifted verbatim from the deleted `ui/classifier.rs`, where it was | |
| 1805 | + | /// `sanitize_filename`, underscore and all. | |
| 1806 | + | fn export_filename(name: &str) -> String { | |
| 1807 | + | let cleaned: String = name | |
| 1808 | + | .trim() | |
| 1809 | + | .chars() | |
| 1810 | + | .map(|c| { | |
| 1811 | + | if c.is_alphanumeric() || c == '-' || c == '_' { | |
| 1812 | + | c | |
| 1813 | + | } else { | |
| 1814 | + | '_' | |
| 1815 | + | } | |
| 1816 | + | }) | |
| 1817 | + | .collect(); | |
| 1818 | + | if cleaned.is_empty() { | |
| 1819 | + | "classifier".to_owned() | |
| 1820 | + | } else { | |
| 1821 | + | cleaned | |
| 1822 | + | } | |
| 1823 | + | } | |
| 1824 | + | ||
| 1573 | 1825 | /// Put a tag on the selected sample, the way the shipped panel does. | |
| 1574 | 1826 | /// | |
| 1575 | 1827 | /// Validated here rather than in the route, because validation is the app's: | |
| @@ -2343,6 +2595,7 @@ | |||
| 2343 | 2595 | let filters = FromFilters { state, intents }; | |
| 2344 | 2596 | let storage = FromStorage { state, intents }; | |
| 2345 | 2597 | let trash = FromTrash { state, intents }; | |
| 2598 | + | let classifier = FromClassifier { state, intents }; | |
| 2346 | 2599 | let panels = Panels { | |
| 2347 | 2600 | config: &config, | |
| 2348 | 2601 | sync, | |
| @@ -2362,6 +2615,7 @@ | |||
| 2362 | 2615 | filters: &filters, | |
| 2363 | 2616 | storage: &storage, | |
| 2364 | 2617 | trash: &trash, | |
| 2618 | + | classifier: &classifier, | |
| 2365 | 2619 | themes, | |
| 2366 | 2620 | }; | |
| 2367 | 2621 | super::router() |
| @@ -42,7 +42,7 @@ | |||
| 42 | 42 | //! | Advanced | **half** | export yes as of quasi 0.50.0; import still a host dialog | | |
| 43 | 43 | //! | License | **no** | a key exchanged with a server | | |
| 44 | 44 | //! | Trash | yes | as of the section's own port; see [`trash`](super::trash) | | |
| 45 | - | //! | Classifier | **no** | its own model state, and bespoke | | |
| 45 | + | //! | Classifier | yes | not a section at all; see [`classifier`](super::classifier) | | |
| 46 | 46 | //! | |
| 47 | 47 | //! Storage was the honest kind of "no" until it was counted. It is described | |
| 48 | 48 | //! now, in [`storage`](super::storage), and what its header records is that two | |
| @@ -204,6 +204,20 @@ | |||
| 204 | 204 | body = super::storage::section(body, state); | |
| 205 | 205 | body = super::trash::section(body, state); | |
| 206 | 206 | ||
| 207 | + | // The classifier's door. It was five sections drawn inline here until | |
| 208 | + | // 2026-08-25, when Max ruled it a window of its own; what stays behind is | |
| 209 | + | // one act, so the thing is still findable from where it used to live. | |
| 210 | + | // See `super::classifier`'s header. | |
| 211 | + | body = body | |
| 212 | + | .with(Node::section("Tagging")) | |
| 213 | + | .with(Node::text( | |
| 214 | + | "Rules, auto-tagging, clustering and folder tags, with the classifiers you have imported.", | |
| 215 | + | )) | |
| 216 | + | .with(Node::Act(Act::new( | |
| 217 | + | "Tag classifier...", | |
| 218 | + | Action::get("/classifier"), | |
| 219 | + | ))); | |
| 220 | + | ||
| 207 | 221 | // Advanced, half of it. See the header: Export Current is describable as of | |
| 208 | 222 | // quasi 0.50.0 and Import Theme is not, so the section is what the | |
| 209 | 223 | // vocabulary can say rather than all-or-nothing. |
| @@ -12,15 +12,16 @@ | |||
| 12 | 12 | use quasi_router::{Method, Node, Outcome, Params, Request, Response, Screen}; | |
| 13 | 13 | ||
| 14 | 14 | use super::{ | |
| 15 | - | Analysed, Analysis, Bar, Bulk, Candidate, Channels, Chop, Chosen, Collection, ColumnsShown, | |
| 16 | - | Config, Coverage, Crumb, Decision, Deleted, Detail, Detailed, DeviceChoice, Draft, Editing, | |
| 17 | - | Export, Failure, Files, Filter, Filters, Focus, Folder, FolderTags, Forge, Forging, Format, | |
| 18 | - | Group, Halted, Holding, Importing, Integrity, Keys, Knob, Library, LibraryEntry, Measure, | |
| 19 | - | Measures, Migrating, Naming, Narrowing, Order, Panel, Panels, Phase, Playing, Preflight, | |
| 20 | - | Pricing, ProfileChoice, Queue, Queued, Reviewed, Sample, Saying, Scan, Scope, Searching, | |
| 21 | - | Setting, Settings, Shared, Shell, Source, Spread, Stage, State, Status, Storage, Strategy, | |
| 22 | - | Subject, Subscription, Suggested, Suggestion, Sweep, Sync, Tagged, ThemeChoice, Trash, Vault, | |
| 23 | - | VaultChoice, Walked, Where, router, | |
| 15 | + | Analysed, Analysis, Authoring, Bar, Bulk, Candidate, Channels, Chop, Chosen, Classifier, | |
| 16 | + | Collection, ColumnsShown, Config, Coverage, Crumb, Decision, Deleted, Detail, Detailed, | |
| 17 | + | DeviceChoice, Doing, Draft, Editing, Export, Failure, Files, Filter, Filters, Focus, Folder, | |
| 18 | + | FolderTags, Forge, Forging, Format, Group, Halted, Harvested, Head, Holding, Importing, | |
| 19 | + | Integrity, Keys, Knob, Layer, Library, LibraryEntry, Measure, Measures, Migrating, Naming, | |
| 20 | + | Narrowing, Order, Panel, Panels, Part, Phase, Pile, Playing, Policy, Preflight, Pricing, | |
| 21 | + | ProfileChoice, Queue, Queued, Readiness, Reviewed, Ruling, Sample, Saying, Scan, Scope, | |
| 22 | + | Searching, Setting, Settings, Shareable, Shared, Sharing, Shell, Source, Spread, Stage, State, | |
| 23 | + | Status, Storage, Strategy, Subject, Subscription, Suggested, Suggestion, Sweep, Sync, Tagged, | |
| 24 | + | Testable, Testing, ThemeChoice, Trash, Vault, VaultChoice, Walked, Where, router, | |
| 24 | 25 | }; | |
| 25 | 26 | ||
| 26 | 27 | /// A config store in memory. | |
| @@ -267,6 +268,7 @@ | |||
| 267 | 268 | filters: &Unfiltered, | |
| 268 | 269 | storage: &OneLibrary, | |
| 269 | 270 | trash: &Emptied, | |
| 271 | + | classifier: &Untrained, | |
| 270 | 272 | themes: &themes, | |
| 271 | 273 | }; | |
| 272 | 274 | router().handle(&state, request) | |
| @@ -434,6 +436,7 @@ | |||
| 434 | 436 | filters: &Unfiltered, | |
| 435 | 437 | storage: &OneLibrary, | |
| 436 | 438 | trash: &Emptied, | |
| 439 | + | classifier: &Untrained, | |
| 437 | 440 | themes: &themes, | |
| 438 | 441 | }; | |
| 439 | 442 | router().handle(&state, request) | |
| @@ -497,6 +500,7 @@ | |||
| 497 | 500 | filters: &Unfiltered, | |
| 498 | 501 | storage: &OneLibrary, | |
| 499 | 502 | trash: &Emptied, | |
| 503 | + | classifier: &Untrained, | |
| 500 | 504 | themes: &themes, | |
| 501 | 505 | }; | |
| 502 | 506 | router().handle(&state, request) | |
| @@ -663,6 +667,7 @@ | |||
| 663 | 667 | filters: &Unfiltered, | |
| 664 | 668 | storage: &OneLibrary, | |
| 665 | 669 | trash: &Emptied, | |
| 670 | + | classifier: &Untrained, | |
| 666 | 671 | themes: &themes, | |
| 667 | 672 | }; | |
| 668 | 673 | let response = router() | |
| @@ -745,6 +750,7 @@ | |||
| 745 | 750 | filters: &Unfiltered, | |
| 746 | 751 | storage: &OneLibrary, | |
| 747 | 752 | trash: &Emptied, | |
| 753 | + | classifier: &Untrained, | |
| 748 | 754 | themes: &themes, | |
| 749 | 755 | }; | |
| 750 | 756 | router().handle(&state, request) | |
| @@ -818,6 +824,7 @@ | |||
| 818 | 824 | filters: &Unfiltered, | |
| 819 | 825 | storage: &OneLibrary, | |
| 820 | 826 | trash: &Emptied, | |
| 827 | + | classifier: &Untrained, | |
| 821 | 828 | themes: &themes, | |
| 822 | 829 | }; | |
| 823 | 830 | ||
| @@ -871,6 +878,7 @@ | |||
| 871 | 878 | filters: &Unfiltered, | |
| 872 | 879 | storage: &OneLibrary, | |
| 873 | 880 | trash: &Emptied, | |
| 881 | + | classifier: &Untrained, | |
| 874 | 882 | themes: &themes, | |
| 875 | 883 | }; | |
| 876 | 884 | let refused = router().handle( | |
| @@ -910,6 +918,7 @@ | |||
| 910 | 918 | filters: &Unfiltered, | |
| 911 | 919 | storage: &OneLibrary, | |
| 912 | 920 | trash: &Emptied, | |
| 921 | + | classifier: &Untrained, | |
| 913 | 922 | themes: &themes, | |
| 914 | 923 | }; | |
| 915 | 924 | ||
| @@ -972,6 +981,7 @@ | |||
| 972 | 981 | filters: &Unfiltered, | |
| 973 | 982 | storage: &OneLibrary, | |
| 974 | 983 | trash: &Emptied, | |
| 984 | + | classifier: &Untrained, | |
| 975 | 985 | themes: &themes, | |
| 976 | 986 | }; | |
| 977 | 987 | let response = router() | |
| @@ -1030,6 +1040,7 @@ | |||
| 1030 | 1040 | filters: &Unfiltered, | |
| 1031 | 1041 | storage: &OneLibrary, | |
| 1032 | 1042 | trash: &Emptied, | |
| 1043 | + | classifier: &Untrained, | |
| 1033 | 1044 | themes: &themes, | |
| 1034 | 1045 | }; | |
| 1035 | 1046 | let response = router() | |
| @@ -1201,6 +1212,7 @@ | |||
| 1201 | 1212 | filters: &Unfiltered, | |
| 1202 | 1213 | storage: &OneLibrary, | |
| 1203 | 1214 | trash: &Emptied, | |
| 1215 | + | classifier: &Untrained, | |
| 1204 | 1216 | themes: &themes, | |
| 1205 | 1217 | }; | |
| 1206 | 1218 | router().handle(&state, request) | |
| @@ -2828,6 +2840,7 @@ | |||
| 2828 | 2840 | filters: &Unfiltered, | |
| 2829 | 2841 | storage: &OneLibrary, | |
| 2830 | 2842 | trash: &Emptied, | |
| 2843 | + | classifier: &Untrained, | |
| 2831 | 2844 | themes: &themes, | |
| 2832 | 2845 | }; | |
| 2833 | 2846 | router().handle(&state, request) | |
| @@ -3384,6 +3397,7 @@ | |||
| 3384 | 3397 | filters: &Unfiltered, | |
| 3385 | 3398 | storage: &OneLibrary, | |
| 3386 | 3399 | trash: &Emptied, | |
| 3400 | + | classifier: &Untrained, | |
| 3387 | 3401 | themes: &themes, | |
| 3388 | 3402 | }; | |
| 3389 | 3403 | router().handle(&state, request) | |
| @@ -3775,6 +3789,7 @@ | |||
| 3775 | 3789 | filters: &Unfiltered, | |
| 3776 | 3790 | storage: &OneLibrary, | |
| 3777 | 3791 | trash: &Emptied, | |
| 3792 | + | classifier: &Untrained, | |
| 3778 | 3793 | themes: &themes, | |
| 3779 | 3794 | }; | |
| 3780 | 3795 | router().handle(&state, request) | |
| @@ -4153,6 +4168,7 @@ | |||
| 4153 | 4168 | filters: &Unfiltered, | |
| 4154 | 4169 | storage: &OneLibrary, | |
| 4155 | 4170 | trash: &Emptied, | |
| 4171 | + | classifier: &Untrained, | |
| 4156 | 4172 | themes: &themes, | |
| 4157 | 4173 | }; | |
| 4158 | 4174 | router().handle(&state, request) | |
| @@ -4576,6 +4592,7 @@ | |||
| 4576 | 4592 | filters: &Unfiltered, | |
| 4577 | 4593 | storage: &OneLibrary, | |
| 4578 | 4594 | trash: &Emptied, | |
| 4595 | + | classifier: &Untrained, | |
| 4579 | 4596 | themes: &themes, | |
| 4580 | 4597 | }; | |
| 4581 | 4598 | router().handle(&state, request) | |
| @@ -5065,6 +5082,7 @@ | |||
| 5065 | 5082 | filters: &Unfiltered, | |
| 5066 | 5083 | storage: &OneLibrary, | |
| 5067 | 5084 | trash: &Emptied, | |
| 5085 | + | classifier: &Untrained, | |
| 5068 | 5086 | themes: &themes, | |
| 5069 | 5087 | }; | |
| 5070 | 5088 | router().handle(&state, request) | |
| @@ -5819,6 +5837,113 @@ | |||
| 5819 | 5837 | fn purge(&self, _hash: &str) {} | |
| 5820 | 5838 | } | |
| 5821 | 5839 | ||
| 5840 | + | /// Nothing tagged, nothing trained, and no rules. | |
| 5841 | + | /// | |
| 5842 | + | /// The quiet fixture, matching [`OneLibrary`] and [`Emptied`]. The screen's own | |
| 5843 | + | /// tests use [`FakeClassifier`], which holds state and records. | |
| 5844 | + | struct Untrained; | |
| 5845 | + | ||
| 5846 | + | impl Classifier for Untrained { | |
| 5847 | + | fn busy(&self) -> Option<String> { | |
| 5848 | + | None | |
| 5849 | + | } | |
| 5850 | + | fn failed(&self) -> Option<String> { | |
| 5851 | + | None | |
| 5852 | + | } | |
| 5853 | + | fn rules(&self) -> Vec<Ruling> { | |
| 5854 | + | Vec::new() | |
| 5855 | + | } | |
| 5856 | + | fn testable(&self) -> Vec<Testable> { | |
| 5857 | + | Vec::new() | |
| 5858 | + | } | |
| 5859 | + | fn authoring(&self) -> Option<Authoring> { | |
| 5860 | + | None | |
| 5861 | + | } | |
| 5862 | + | fn last_apply(&self) -> Option<usize> { | |
| 5863 | + | None | |
| 5864 | + | } | |
| 5865 | + | fn last_suggest(&self) -> Option<usize> { | |
| 5866 | + | None | |
| 5867 | + | } | |
| 5868 | + | fn last_review(&self) -> Option<String> { | |
| 5869 | + | None | |
| 5870 | + | } | |
| 5871 | + | fn waiting(&self) -> usize { | |
| 5872 | + | 0 | |
| 5873 | + | } | |
| 5874 | + | fn head(&self) -> Option<Head> { | |
| 5875 | + | None | |
| 5876 | + | } | |
| 5877 | + | fn readiness(&self) -> Option<Readiness> { | |
| 5878 | + | None | |
| 5879 | + | } | |
| 5880 | + | fn policies(&self) -> Vec<Policy> { | |
| 5881 | + | Vec::new() | |
| 5882 | + | } | |
| 5883 | + | fn policy_typing(&self) -> String { | |
| 5884 | + | String::new() | |
| 5885 | + | } | |
| 5886 | + | fn cluster_k(&self) -> u32 { | |
| 5887 | + | 8 | |
| 5888 | + | } | |
| 5889 | + | fn clusters(&self) -> Vec<Pile> { | |
| 5890 | + | Vec::new() | |
| 5891 | + | } | |
| 5892 | + | fn folders(&self) -> Option<Vec<Harvested>> { | |
| 5893 | + | None | |
| 5894 | + | } | |
| 5895 | + | fn sharing(&self) -> Sharing { | |
| 5896 | + | Sharing::default() | |
| 5897 | + | } | |
| 5898 | + | fn layers(&self) -> Vec<Layer> { | |
| 5899 | + | Vec::new() | |
| 5900 | + | } | |
| 5901 | + | ||
| 5902 | + | fn new_rule(&self) {} | |
| 5903 | + | fn apply_rules(&self) {} | |
| 5904 | + | fn seed_rules(&self) {} | |
| 5905 | + | fn enable_rule(&self, _id: &str, _on: bool) {} | |
| 5906 | + | fn edit_rule(&self, _id: &str) {} | |
| 5907 | + | fn delete_rule(&self, _id: &str) {} | |
| 5908 | + | fn move_rule(&self, _id: &str, _up: bool) {} | |
| 5909 | + | fn name_draft(&self, _name: &str) {} | |
| 5910 | + | fn enable_draft(&self, _on: bool) {} | |
| 5911 | + | fn match_all(&self, _all: bool) {} | |
| 5912 | + | fn add_condition(&self) {} | |
| 5913 | + | fn drop_condition(&self, _at: usize) {} | |
| 5914 | + | fn set_condition(&self, _at: usize, _part: Part, _value: &str) {} | |
| 5915 | + | fn add_action(&self) {} | |
| 5916 | + | fn drop_action(&self, _at: usize) {} | |
| 5917 | + | fn set_action(&self, _at: usize, _part: Part, _value: &str) {} | |
| 5918 | + | fn test_draft(&self) {} | |
| 5919 | + | fn save_draft(&self) {} | |
| 5920 | + | fn cancel_draft(&self) {} | |
| 5921 | + | fn suggest(&self) {} | |
| 5922 | + | fn undo_source(&self, _source: &str) {} | |
| 5923 | + | fn review(&self) {} | |
| 5924 | + | fn reopen_review(&self) {} | |
| 5925 | + | fn train(&self) {} | |
| 5926 | + | fn clear_head(&self) {} | |
| 5927 | + | fn set_policy(&self, _tag: &str, _review: f64, _auto: f64) {} | |
| 5928 | + | fn typing_policy(&self, _tag: &str) {} | |
| 5929 | + | fn add_policy(&self) {} | |
| 5930 | + | fn set_cluster_k(&self, _k: u32) {} | |
| 5931 | + | fn find_clusters(&self) {} | |
| 5932 | + | fn play_cluster(&self, _at: usize) {} | |
| 5933 | + | fn name_cluster(&self, _at: usize, _name: &str) {} | |
| 5934 | + | fn tag_cluster(&self, _at: usize) {} | |
| 5935 | + | fn scan_folders(&self) {} | |
| 5936 | + | fn name_folder(&self, _at: usize, _tag: &str) {} | |
| 5937 | + | fn apply_folder(&self, _at: usize) {} | |
| 5938 | + | fn name_export(&self, _name: &str) {} | |
| 5939 | + | fn include(&self, _part: Shareable, _on: bool) {} | |
| 5940 | + | fn export(&self) {} | |
| 5941 | + | fn import(&self) {} | |
| 5942 | + | fn enable_layer(&self, _id: &str, _on: bool) {} | |
| 5943 | + | fn weigh_layer(&self, _id: &str, _weight: f64) {} | |
| 5944 | + | fn remove_layer(&self, _id: &str) {} | |
| 5945 | + | } | |
| 5946 | + | ||
| 5822 | 5947 | /// A namer in memory, recording what was asked of it and refusing on demand. | |
| 5823 | 5948 | #[derive(Default)] | |
| 5824 | 5949 | struct FakeNaming { | |
| @@ -5942,6 +6067,7 @@ | |||
| 5942 | 6067 | filters: &Unfiltered, | |
| 5943 | 6068 | storage: &OneLibrary, | |
| 5944 | 6069 | trash: &Emptied, | |
| 6070 | + | classifier: &Untrained, | |
| 5945 | 6071 | themes: &themes, | |
| 5946 | 6072 | }; | |
| 5947 | 6073 | router().handle(&state, request) | |
| @@ -6106,6 +6232,7 @@ | |||
| 6106 | 6232 | filters: &Unfiltered, | |
| 6107 | 6233 | storage: &OneLibrary, | |
| 6108 | 6234 | trash: &Emptied, | |
| 6235 | + | classifier: &Untrained, | |
| 6109 | 6236 | themes: &themes, | |
| 6110 | 6237 | }; | |
| 6111 | 6238 | router().handle(&state, request) | |
| @@ -6414,6 +6541,7 @@ | |||
| 6414 | 6541 | filters: &Unfiltered, | |
| 6415 | 6542 | storage: &OneLibrary, | |
| 6416 | 6543 | trash: &Emptied, | |
| 6544 | + | classifier: &Untrained, | |
| 6417 | 6545 | themes: &themes, | |
| 6418 | 6546 | }; | |
| 6419 | 6547 | router().handle(&state, request) | |
| @@ -6499,6 +6627,7 @@ | |||
| 6499 | 6627 | filters: &Unfiltered, | |
| 6500 | 6628 | storage: &OneLibrary, | |
| 6501 | 6629 | trash: &Emptied, | |
| 6630 | + | classifier: &Untrained, | |
| 6502 | 6631 | themes: &themes, | |
| 6503 | 6632 | }; | |
| 6504 | 6633 | let response = router().handle(&state, Request::get("/")).unwrap(); | |
| @@ -6682,6 +6811,7 @@ | |||
| 6682 | 6811 | filters: &Unfiltered, | |
| 6683 | 6812 | storage: &OneLibrary, | |
| 6684 | 6813 | trash: &Emptied, | |
| 6814 | + | classifier: &Untrained, | |
| 6685 | 6815 | themes: &themes, | |
| 6686 | 6816 | }; | |
| 6687 | 6817 | router().handle(&state, request) | |
| @@ -8261,6 +8391,7 @@ | |||
| 8261 | 8391 | filters: &Unfiltered, | |
| 8262 | 8392 | storage: &OneLibrary, | |
| 8263 | 8393 | trash: &Emptied, | |
| 8394 | + | classifier: &Untrained, | |
| 8264 | 8395 | themes: &themes, | |
| 8265 | 8396 | }; | |
| 8266 | 8397 | router().handle(&state, request) | |
| @@ -8843,6 +8974,7 @@ | |||
| 8843 | 8974 | filters, | |
| 8844 | 8975 | storage: &OneLibrary, | |
| 8845 | 8976 | trash: &Emptied, | |
| 8977 | + | classifier: &Untrained, | |
| 8846 | 8978 | themes: &themes, | |
| 8847 | 8979 | }; | |
| 8848 | 8980 | router().handle(&state, request) | |
| @@ -8983,6 +9115,7 @@ | |||
| 8983 | 9115 | filters: &Unfiltered, | |
| 8984 | 9116 | storage: &OneLibrary, | |
| 8985 | 9117 | trash: &Emptied, | |
| 9118 | + | classifier: &Untrained, | |
| 8986 | 9119 | themes: &themes, | |
| 8987 | 9120 | }; | |
| 8988 | 9121 | router().handle(&state, request) | |
| @@ -9676,6 +9809,7 @@ | |||
| 9676 | 9809 | filters: &Unfiltered, | |
| 9677 | 9810 | storage, | |
| 9678 | 9811 | trash: &Emptied, | |
| 9812 | + | classifier: &Untrained, | |
| 9679 | 9813 | themes: &themes, | |
| 9680 | 9814 | }; | |
| 9681 | 9815 | router().handle(&state, request) | |
| @@ -10099,6 +10233,7 @@ | |||
| 10099 | 10233 | filters: &Unfiltered, | |
| 10100 | 10234 | storage: &OneLibrary, | |
| 10101 | 10235 | trash, | |
| 10236 | + | classifier: &Untrained, | |
| 10102 | 10237 | themes: &themes, | |
| 10103 | 10238 | }; | |
| 10104 | 10239 | router().handle(&state, request) | |
| @@ -10265,3 +10400,896 @@ | |||
| 10265 | 10400 | ); | |
| 10266 | 10401 | } | |
| 10267 | 10402 | } | |
| 10403 | + | ||
| 10404 | + | /// A classifier in memory, recording what was asked of it. | |
| 10405 | + | #[derive(Default)] | |
| 10406 | + | struct FakeClassifier { | |
| 10407 | + | busy: Option<String>, | |
| 10408 | + | failed: Option<String>, | |
| 10409 | + | rules: Vec<Ruling>, | |
| 10410 | + | authoring: Option<Authoring>, | |
| 10411 | + | waiting: usize, | |
| 10412 | + | head: Option<Head>, | |
| 10413 | + | readiness: Option<Readiness>, | |
| 10414 | + | policies: Vec<Policy>, | |
| 10415 | + | clusters: Vec<Pile>, | |
| 10416 | + | folders: Option<Vec<Harvested>>, | |
| 10417 | + | sharing: Sharing, | |
| 10418 | + | layers: Vec<Layer>, | |
| 10419 | + | asked: RefCell<Vec<String>>, | |
| 10420 | + | } | |
| 10421 | + | ||
| 10422 | + | impl FakeClassifier { | |
| 10423 | + | /// Two rules, nothing else going on. | |
| 10424 | + | fn two_rules() -> Self { | |
| 10425 | + | Self { | |
| 10426 | + | rules: vec![ | |
| 10427 | + | Ruling { | |
| 10428 | + | id: "r1".to_owned(), | |
| 10429 | + | name: "Kick drums".to_owned(), | |
| 10430 | + | enabled: true, | |
| 10431 | + | conditions: 2, | |
| 10432 | + | actions: 1, | |
| 10433 | + | first: true, | |
| 10434 | + | last: false, | |
| 10435 | + | }, | |
| 10436 | + | Ruling { | |
| 10437 | + | id: "r2".to_owned(), | |
| 10438 | + | name: String::new(), | |
| 10439 | + | enabled: false, | |
| 10440 | + | conditions: 1, | |
| 10441 | + | actions: 1, | |
| 10442 | + | first: false, | |
| 10443 | + | last: true, | |
| 10444 | + | }, | |
| 10445 | + | ], | |
| 10446 | + | ..Self::default() | |
| 10447 | + | } | |
| 10448 | + | } | |
| 10449 | + | ||
| 10450 | + | /// An editor open on one condition and one action. | |
| 10451 | + | fn editing() -> Self { | |
| 10452 | + | Self { | |
| 10453 | + | authoring: Some(Authoring { | |
| 10454 | + | name: "Kick drums".to_owned(), | |
| 10455 | + | enabled: true, | |
| 10456 | + | all: true, | |
| 10457 | + | conditions: vec![Testing { | |
| 10458 | + | field: "name".to_owned(), | |
| 10459 | + | op: "contains".to_owned(), | |
| 10460 | + | value: "kick".to_owned(), | |
| 10461 | + | }], | |
| 10462 | + | actions: vec![Doing { | |
| 10463 | + | kind: "add".to_owned(), | |
| 10464 | + | tag: "instrument.drum.kick".to_owned(), | |
| 10465 | + | }], | |
| 10466 | + | matched: None, | |
| 10467 | + | refused: false, | |
| 10468 | + | }), | |
| 10469 | + | ..Self::default() | |
| 10470 | + | } | |
| 10471 | + | } | |
| 10472 | + | ||
| 10473 | + | fn asked(&self) -> Vec<String> { | |
| 10474 | + | self.asked.borrow().clone() | |
| 10475 | + | } | |
| 10476 | + | ||
| 10477 | + | fn note(&self, what: impl Into<String>) { | |
| 10478 | + | self.asked.borrow_mut().push(what.into()); | |
| 10479 | + | } | |
| 10480 | + | } | |
| 10481 | + | ||
| 10482 | + | impl Classifier for FakeClassifier { | |
| 10483 | + | fn busy(&self) -> Option<String> { | |
| 10484 | + | self.busy.clone() | |
| 10485 | + | } | |
| 10486 | + | fn failed(&self) -> Option<String> { | |
| 10487 | + | self.failed.clone() | |
| 10488 | + | } | |
| 10489 | + | fn rules(&self) -> Vec<Ruling> { | |
| 10490 | + | self.rules.clone() | |
| 10491 | + | } | |
| 10492 | + | /// The real vocabulary, so the screen is tested against what it will be | |
| 10493 | + | /// handed rather than against a stub of it. | |
| 10494 | + | fn testable(&self) -> Vec<Testable> { | |
| 10495 | + | super::rule_fields() | |
| 10496 | + | .iter() | |
| 10497 | + | .map(|field| Testable { | |
| 10498 | + | value: super::field_value(*field).to_owned(), | |
| 10499 | + | label: super::field_label(*field).to_owned(), | |
| 10500 | + | ops: super::ops_for(*field) | |
| 10501 | + | .iter() | |
| 10502 | + | .map(|op| super::Comparison { | |
| 10503 | + | value: super::op_value(*op).to_owned(), | |
| 10504 | + | label: super::op_label(*op).to_owned(), | |
| 10505 | + | takes_value: super::op_takes_value(*op), | |
| 10506 | + | }) | |
| 10507 | + | .collect(), | |
| 10508 | + | }) | |
| 10509 | + | .collect() | |
| 10510 | + | } | |
| 10511 | + | fn authoring(&self) -> Option<Authoring> { | |
| 10512 | + | self.authoring.clone() | |
| 10513 | + | } | |
| 10514 | + | fn last_apply(&self) -> Option<usize> { | |
| 10515 | + | None | |
| 10516 | + | } | |
| 10517 | + | fn last_suggest(&self) -> Option<usize> { | |
| 10518 | + | None | |
| 10519 | + | } | |
| 10520 | + | fn last_review(&self) -> Option<String> { | |
| 10521 | + | None | |
| 10522 | + | } | |
| 10523 | + | fn waiting(&self) -> usize { | |
| 10524 | + | self.waiting | |
| 10525 | + | } | |
| 10526 | + | fn head(&self) -> Option<Head> { | |
| 10527 | + | self.head | |
| 10528 | + | } | |
| 10529 | + | fn readiness(&self) -> Option<Readiness> { | |
| 10530 | + | self.readiness | |
| 10531 | + | } | |
| 10532 | + | fn policies(&self) -> Vec<Policy> { | |
| 10533 | + | self.policies.clone() | |
| 10534 | + | } | |
| 10535 | + | fn policy_typing(&self) -> String { | |
| 10536 | + | String::new() | |
| 10537 | + | } | |
| 10538 | + | fn cluster_k(&self) -> u32 { | |
| 10539 | + | 8 | |
| 10540 | + | } | |
| 10541 | + | fn clusters(&self) -> Vec<Pile> { | |
| 10542 | + | self.clusters.clone() | |
| 10543 | + | } | |
| 10544 | + | fn folders(&self) -> Option<Vec<Harvested>> { | |
| 10545 | + | self.folders.clone() | |
| 10546 | + | } | |
| 10547 | + | fn sharing(&self) -> Sharing { | |
| 10548 | + | self.sharing.clone() | |
| 10549 | + | } | |
| 10550 | + | fn layers(&self) -> Vec<Layer> { | |
| 10551 | + | self.layers.clone() | |
| 10552 | + | } | |
| 10553 | + | ||
| 10554 | + | fn new_rule(&self) { | |
| 10555 | + | self.note("new_rule"); | |
| 10556 | + | } | |
| 10557 | + | fn apply_rules(&self) { | |
| 10558 | + | self.note("apply_rules"); | |
| 10559 | + | } | |
| 10560 | + | fn seed_rules(&self) { | |
| 10561 | + | self.note("seed_rules"); | |
| 10562 | + | } | |
| 10563 | + | fn enable_rule(&self, id: &str, on: bool) { | |
| 10564 | + | self.note(format!("enable_rule {id} {on}")); | |
| 10565 | + | } | |
| 10566 | + | fn edit_rule(&self, id: &str) { | |
| 10567 | + | self.note(format!("edit_rule {id}")); | |
| 10568 | + | } | |
| 10569 | + | fn delete_rule(&self, id: &str) { | |
| 10570 | + | self.note(format!("delete_rule {id}")); | |
| 10571 | + | } | |
| 10572 | + | fn move_rule(&self, id: &str, up: bool) { |
Lines truncated
| @@ -330,7 +330,8 @@ | |||
| 330 | 330 | pub trash_loaded: bool, | |
| 331 | 331 | } | |
| 332 | 332 | ||
| 333 | - | /// GUI state for the tag-classifier sections in Settings (Layer A rules builder). | |
| 333 | + | /// GUI state for the tag classifier, which is a window of its own since | |
| 334 | + | /// 2026-08-25 (Max's ruling; it had been a Settings section). | |
| 334 | 335 | #[derive(Default)] | |
| 335 | 336 | pub struct ClassifierUiState { | |
| 336 | 337 | /// Cached rules, refreshed when the section opens and after edits. | |
| @@ -402,8 +403,6 @@ | |||
| 402 | 403 | pub busy: Option<String>, | |
| 403 | 404 | /// Last classifier-job error, shown as an in-section banner. | |
| 404 | 405 | pub last_error: Option<String>, | |
| 405 | - | /// Layer id awaiting a remove confirmation (inline two-step confirm). | |
| 406 | - | pub pending_layer_remove: Option<String>, | |
| 407 | 406 | } | |
| 408 | 407 | ||
| 409 | 408 | /// A rule being authored in the builder. Mirrors `audiofiles_core::rules::Rule` |
| @@ -1,6 +1,5 @@ | |||
| 1 | 1 | //! UI submodules: each panel and widget type in its own file. | |
| 2 | 2 | ||
| 3 | - | pub mod classifier; | |
| 4 | 3 | pub mod color; | |
| 5 | 4 | pub mod dialog; | |
| 6 | 5 | pub mod file_list_menus; |
| @@ -1,0 +1,1310 @@ | |||
| 1 | + | //! The tag classifier, described: rules, auto-tagging, clustering, folder tags | |
| 2 | + | //! and the `.afcl` files that carry all of it between libraries. | |
| 3 | + | //! | |
| 4 | + | //! The seventeenth port and the largest by a distance: `ui/classifier.rs` was | |
| 5 | + | //! 1,432 lines and is deleted with this one. | |
| 6 | + | //! | |
| 7 | + | //! # It is not a settings section, and Max ruled that before a route was written | |
| 8 | + | //! | |
| 9 | + | //! It arrived as one because `draw_settings_panel` called it between Display | |
| 10 | + | //! and License, and describing it there would have been repeating an accident | |
| 11 | + | //! of where the call sat. The tree had already half-answered the question: the | |
| 12 | + | //! review queue this screen launches is `/review`, a top-level address served | |
| 13 | + | //! by [`queue`](super::queue) and taking the whole browser area, and every other | |
| 14 | + | //! subsystem here — [`edit`](super::edit), [`forge`](super::forge), | |
| 15 | + | //! [`export`](super::export) — has an address and a window of its own. | |
| 16 | + | //! | |
| 17 | + | //! **Ruled 2026-08-25 (Max): one screen at `/classifier`, five sections inside | |
| 18 | + | //! it, with the door in Settings.** Not five addresses with a navigation | |
| 19 | + | //! between them, which was the alternative: the five are read together and the | |
| 20 | + | //! rules editor is the only one that takes the body over. | |
| 21 | + | //! | |
| 22 | + | //! The door is a `GET`, which is what the toolbar's own Settings, Cloud Sync | |
| 23 | + | //! and Help acts are, and it lands where they land: the shell runtime navigates | |
| 24 | + | //! and the screen is drawn inline. `draw_settings` and `draw_sync` are windows | |
| 25 | + | //! for the same screens and neither is reachable — the doors that set their | |
| 26 | + | //! flags were the shipped sidebar's and went with `49b7429`. That is a hole | |
| 27 | + | //! this port found rather than one it made, and adding a sixth window behind a | |
| 28 | + | //! flag nothing sets would have been a third copy of it. | |
| 29 | + | //! | |
| 30 | + | //! # Every control is an intent, without a single exception | |
| 31 | + | //! | |
| 32 | + | //! Unusual, and it falls out of the app rather than being a choice: all | |
| 33 | + | //! thirty-odd `classifier_*` methods on `BrowserState` take `&mut self`, and | |
| 34 | + | //! several of them start work on another thread. So there is no handle to call | |
| 35 | + | //! the way [`naming`](super::naming) calls `create_vfs`, and the rule about | |
| 36 | + | //! *what the app does about a write* never has to be applied here. | |
| 37 | + | //! | |
| 38 | + | //! # The screen is handed the rule vocabulary rather than holding it | |
| 39 | + | //! | |
| 40 | + | //! [`Classifier::testable`] is the member that shape depends on. `FIELDS`, | |
| 41 | + | //! `ops_for` and `op_needs_value` were in the drawing file, which put "what a | |
| 42 | + | //! rule can ask about a sample" one `RuleField` away from having to be added in | |
| 43 | + | //! two places. They are the app's now, and the description renders what it is | |
| 44 | + | //! given: the field select lists what came back, and the operator select lists | |
| 45 | + | //! what *that field* came back with. A described screen that narrowed the | |
| 46 | + | //! operators itself would be a second copy of `audiofiles_core::rules`. | |
| 47 | + | //! | |
| 48 | + | //! # THE FINDING: a repeating question repeats one field, and a condition is three | |
| 49 | + | //! | |
| 50 | + | //! The task filed against this port named [`Field::repeats`] as the member to | |
| 51 | + | //! read first, on the reading that a rule's conditions are one question | |
| 52 | + | //! answered N times. **Counted, they are not.** A condition is a testable, a | |
| 53 | + | //! comparison and an operand — three questions whose answers only mean anything | |
| 54 | + | //! together — and [`Repeat`] holds a `Vec<Instance>` where an instance is one | |
| 55 | + | //! value and one error. An action is two questions the same way. | |
| 56 | + | //! | |
| 57 | + | //! So the editor does not use it. Each condition is a [`Node::Region`] holding | |
| 58 | + | //! its three fields, with its own remove act, and "Add condition" is an act | |
| 59 | + | //! rather than a renderer's add control. That says everything honestly and | |
| 60 | + | //! loses one thing the member would have given: a renderer has no way to know | |
| 61 | + | //! the regions are slots of one repeating question, so nothing can draw them as | |
| 62 | + | //! a list that grows. Filed on quasicoherent rather than worked around further. | |
| 63 | + | //! | |
| 64 | + | //! Two consumers here, not one: conditions (three fields) and actions (two). | |
| 65 | + | //! Both are in the same editor, which is what makes this a shape rather than a | |
| 66 | + | //! quirk of one screen. | |
| 67 | + | //! | |
| 68 | + | //! # THE SECOND FINDING: a description cannot say "write when the control settles" | |
| 69 | + | //! | |
| 70 | + | //! The shipped thresholds and the layer weights both commit through | |
| 71 | + | //! `widgets::settled`, so a drag across a slider is one write rather than one | |
| 72 | + | //! per frame. [`Field::changes`] is the only thing the vocabulary has and it | |
| 73 | + | //! means every change. Three sliders here take it, so a drag is now a write per | |
| 74 | + | //! step: correct, and chattier than the shipped screen against a store that is | |
| 75 | + | //! local. | |
| 76 | + | //! | |
| 77 | + | //! Not worked around, because the workaround would be worse — a submit button | |
| 78 | + | //! per slider, or a debounce invented in one renderer. Filed with a count. | |
| 79 | + | //! | |
| 80 | + | //! # Two `ConfirmAction`-shaped state machines die here | |
| 81 | + | //! | |
| 82 | + | //! `pending_layer_remove` armed a layer's Remove and swapped the row for a | |
| 83 | + | //! Cancel/Remove pair, exactly as `trash_confirm_purge` did before | |
| 84 | + | //! [`trash`](super::trash) deleted it. [`Act::confirm`] again, and the field | |
| 85 | + | //! goes with it. That is the fifth and sixth variant of that pattern this port | |
| 86 | + | //! has replaced with a builder call. | |
| 87 | + | //! | |
| 88 | + | //! # What stays an intent because the host has to be asked | |
| 89 | + | //! | |
| 90 | + | //! Export writes a file the reader names a place for, and import reads one. | |
| 91 | + | //! Export could be [`Outcome::File`] — [`settings`](super::settings)'s theme | |
| 92 | + | //! export is that member's first consumer here — but the classifier's export is | |
| 93 | + | //! built on a worker thread and answers through `BackendEvent`, so the route | |
| 94 | + | //! has no bytes to hand over when it answers. Import is the gap | |
| 95 | + | //! [`settings`](super::settings) already records: `FieldKind::File` says what | |
| 96 | + | //! may be picked and nothing carries the picked file's bytes to a route on this | |
| 97 | + | //! host. Both are ordinary acts, and both are the same host plumbing the import | |
| 98 | + | //! flow's four doors take. | |
| 99 | + | //! | |
| 100 | + | //! [`Act::confirm`]: quasi_router::Act::confirm | |
| 101 | + | //! [`Classifier::testable`]: super::Classifier::testable | |
| 102 | + | //! [`Field::changes`]: quasi_router::Field::changes | |
| 103 | + | //! [`Field::repeats`]: quasi_router::Field::repeats | |
| 104 | + | //! [`Outcome::File`]: quasi_router::Outcome::File | |
| 105 | + | //! [`Repeat`]: quasi_router::Repeat | |
| 106 | + | ||
| 107 | + | use quasi_router::layout::{FieldKind, Notice, Readiness as Ready, Tone}; | |
| 108 | + | use quasi_router::{ | |
| 109 | + | Act, Action, Choice, Field, Node, RegionKind, Request, Response, RouteError, Router, Row, | |
| 110 | + | Screen, Slot, Tag, | |
| 111 | + | }; | |
| 112 | + | ||
| 113 | + | use super::{Panels, Part, Shareable}; | |
| 114 | + | ||
| 115 | + | /// The region the whole screen answers into. | |
| 116 | + | const BODY: &str = "classifier-body"; | |
| 117 | + | ||
| 118 | + | /// What removing a layer takes with it. | |
| 119 | + | const DROPS_LAYER: &str = | |
| 120 | + | "Permanently delete this layer and its imported rules? This cannot be undone."; | |
| 121 | + | ||
| 122 | + | /// Register the classifier's routes. | |
| 123 | + | pub fn routes(router: Router<Panels<'_>>) -> Router<Panels<'_>> { | |
| 124 | + | let router = router | |
| 125 | + | .get("/classifier", index) | |
| 126 | + | .post("/classifier/rules/new", new_rule) | |
| 127 | + | .post("/classifier/rules/apply", apply_rules) | |
| 128 | + | .post("/classifier/rules/starter", seed_rules) | |
| 129 | + | .post("/classifier/rules/{id}/enabled", enable_rule) | |
| 130 | + | .post("/classifier/rules/{id}/edit", edit_rule) | |
| 131 | + | .post("/classifier/rules/{id}/delete", delete_rule) | |
| 132 | + | .post("/classifier/rules/{id}/up", move_up) | |
| 133 | + | .post("/classifier/rules/{id}/down", move_down); | |
| 134 | + | ||
| 135 | + | let router = router | |
| 136 | + | .post("/classifier/draft/name", name_draft) | |
| 137 | + | .post("/classifier/draft/enabled", enable_draft) | |
| 138 | + | .post("/classifier/draft/match", match_mode) | |
| 139 | + | .post("/classifier/draft/conditions/add", add_condition) | |
| 140 | + | .post("/classifier/draft/conditions/{at}/remove", drop_condition) | |
| 141 | + | .post( | |
| 142 | + | "/classifier/draft/conditions/{at}/set/{part}", | |
| 143 | + | set_condition, | |
| 144 | + | ) | |
| 145 | + | .post("/classifier/draft/actions/add", add_action) | |
| 146 | + | .post("/classifier/draft/actions/{at}/remove", drop_action) | |
| 147 | + | .post("/classifier/draft/actions/{at}/set/{part}", set_action) | |
| 148 | + | .post("/classifier/draft/test", test_draft) | |
| 149 | + | .post("/classifier/draft/save", save_draft) | |
| 150 | + | .post("/classifier/draft/cancel", cancel_draft); | |
| 151 | + | ||
| 152 | + | let router = router | |
| 153 | + | .post("/classifier/autotag/suggest", suggest) | |
| 154 | + | .post("/classifier/autotag/review", review) | |
| 155 | + | .post("/classifier/autotag/reopen", reopen) | |
| 156 | + | .post("/classifier/head/train", train) | |
| 157 | + | .post("/classifier/head/clear", clear_head) | |
| 158 | + | .post("/classifier/policies/typing", typing_policy) | |
| 159 | + | .post("/classifier/policies/add", add_policy) | |
| 160 | + | .post("/classifier/policies/{tag}/thresholds", set_policy); | |
| 161 | + | ||
| 162 | + | let router = router | |
| 163 | + | .post("/classifier/clusters/k", cluster_k) | |
| 164 | + | .post("/classifier/clusters/find", find_clusters) | |
| 165 | + | .post("/classifier/clusters/{at}/play", play_cluster) | |
| 166 | + | .post("/classifier/clusters/{at}/name", name_cluster) | |
| 167 | + | .post("/classifier/clusters/{at}/tag", tag_cluster) | |
| 168 | + | .post("/classifier/folders/scan", scan_folders) | |
| 169 | + | .post("/classifier/folders/{at}/tag", name_folder) | |
| 170 | + | .post("/classifier/folders/{at}/apply", apply_folder) | |
| 171 | + | .post("/classifier/undo/{source}", undo_source); | |
| 172 | + | ||
| 173 | + | router | |
| 174 | + | .post("/classifier/sharing/name", name_export) | |
| 175 | + | .post("/classifier/sharing/include/{part}", include) | |
| 176 | + | .post("/classifier/sharing/export", export) | |
| 177 | + | .post("/classifier/sharing/import", import) | |
| 178 | + | .post("/classifier/layers/{id}/enabled", enable_layer) | |
| 179 | + | .post("/classifier/layers/{id}/weight", weigh_layer) | |
| 180 | + | .post("/classifier/layers/{id}/remove", remove_layer) | |
| 181 | + | } | |
| 182 | + | ||
| 183 | + | // --- Rules ------------------------------------------------------------------- | |
| 184 | + | ||
| 185 | + | /// `GET /classifier` | |
| 186 | + | fn index(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> { | |
| 187 | + | Ok(screen(state).into()) | |
| 188 | + | } | |
| 189 | + | ||
| 190 | + | /// `POST /classifier/rules/new` | |
| 191 | + | fn new_rule(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> { | |
| 192 | + | state.classifier.new_rule(); | |
| 193 | + | settled(state) | |
| 194 | + | } | |
| 195 | + | ||
| 196 | + | /// `POST /classifier/rules/apply` | |
| 197 | + | fn apply_rules(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> { | |
| 198 | + | state.classifier.apply_rules(); | |
| 199 | + | settled(state) | |
| 200 | + | } | |
| 201 | + | ||
| 202 | + | /// `POST /classifier/rules/starter` | |
| 203 | + | fn seed_rules(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> { | |
| 204 | + | state.classifier.seed_rules(); | |
| 205 | + | settled(state) | |
| 206 | + | } | |
| 207 | + | ||
| 208 | + | /// `POST /classifier/rules/{id}/enabled` | |
| 209 | + | fn enable_rule(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> { | |
| 210 | + | let id = named(&request, "id")?; | |
| 211 | + | state.classifier.enable_rule(id, ticked(&request, "on")); | |
| 212 | + | settled(state) | |
| 213 | + | } | |
| 214 | + | ||
| 215 | + | /// `POST /classifier/rules/{id}/edit` | |
| 216 | + | fn edit_rule(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> { | |
| 217 | + | state.classifier.edit_rule(named(&request, "id")?); | |
| 218 | + | settled(state) | |
| 219 | + | } | |
| 220 | + | ||
| 221 | + | /// `POST /classifier/rules/{id}/delete` | |
| 222 | + | fn delete_rule(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> { | |
| 223 | + | state.classifier.delete_rule(named(&request, "id")?); | |
| 224 | + | settled(state) | |
| 225 | + | } | |
| 226 | + | ||
| 227 | + | /// `POST /classifier/rules/{id}/up` | |
| 228 | + | fn move_up(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> { | |
| 229 | + | state.classifier.move_rule(named(&request, "id")?, true); | |
| 230 | + | settled(state) | |
| 231 | + | } | |
| 232 | + | ||
| 233 | + | /// `POST /classifier/rules/{id}/down` | |
| 234 | + | fn move_down(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> { | |
| 235 | + | state.classifier.move_rule(named(&request, "id")?, false); | |
| 236 | + | settled(state) | |
| 237 | + | } | |
| 238 | + | ||
| 239 | + | // --- The rule being authored ------------------------------------------------- | |
| 240 | + | ||
| 241 | + | /// `POST /classifier/draft/name` | |
| 242 | + | fn name_draft(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> { | |
| 243 | + | state | |
| 244 | + | .classifier | |
| 245 | + | .name_draft(request.payload.get("name").unwrap_or_default()); | |
| 246 | + | settled(state) | |
| 247 | + | } | |
| 248 | + | ||
| 249 | + | /// `POST /classifier/draft/enabled` | |
| 250 | + | fn enable_draft(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> { | |
| 251 | + | state.classifier.enable_draft(ticked(&request, "enabled")); | |
| 252 | + | settled(state) | |
| 253 | + | } | |
| 254 | + | ||
| 255 | + | /// `POST /classifier/draft/match` | |
| 256 | + | fn match_mode(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> { | |
| 257 | + | let answered = request.payload.get("match").unwrap_or_default(); | |
| 258 | + | state.classifier.match_all(answered != "any"); | |
| 259 | + | settled(state) | |
| 260 | + | } | |
| 261 | + | ||
| 262 | + | /// `POST /classifier/draft/conditions/add` | |
| 263 | + | fn add_condition(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> { | |
| 264 | + | state.classifier.add_condition(); | |
| 265 | + | settled(state) | |
| 266 | + | } | |
| 267 | + | ||
| 268 | + | /// `POST /classifier/draft/conditions/{at}/remove` | |
| 269 | + | fn drop_condition(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> { | |
| 270 | + | state.classifier.drop_condition(slot(&request)?); | |
| 271 | + | settled(state) | |
| 272 | + | } | |
| 273 | + | ||
| 274 | + | /// `POST /classifier/draft/conditions/{at}/set/{part}` | |
| 275 | + | fn set_condition(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> { | |
| 276 | + | let at = slot(&request)?; | |
| 277 | + | let part = condition_part(&request)?; | |
| 278 | + | let name = request.captures.require("part")?; | |
| 279 | + | state | |
| 280 | + | .classifier | |
| 281 | + | .set_condition(at, part, request.payload.get(name).unwrap_or_default()); | |
| 282 | + | settled(state) | |
| 283 | + | } | |
| 284 | + | ||
| 285 | + | /// `POST /classifier/draft/actions/add` | |
| 286 | + | fn add_action(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> { | |
| 287 | + | state.classifier.add_action(); | |
| 288 | + | settled(state) | |
| 289 | + | } | |
| 290 | + | ||
| 291 | + | /// `POST /classifier/draft/actions/{at}/remove` | |
| 292 | + | fn drop_action(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> { | |
| 293 | + | state.classifier.drop_action(slot(&request)?); | |
| 294 | + | settled(state) | |
| 295 | + | } | |
| 296 | + | ||
| 297 | + | /// `POST /classifier/draft/actions/{at}/set/{part}` | |
| 298 | + | fn set_action(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> { | |
| 299 | + | let at = slot(&request)?; | |
| 300 | + | let name = request.captures.require("part")?; | |
| 301 | + | let part = match name { | |
| 302 | + | "kind" => Part::Kind, | |
| 303 | + | "tag" => Part::Value, | |
| 304 | + | _ => return Err(RouteError::not_found("no such part of an action")), | |
| 305 | + | }; | |
| 306 | + | state | |
| 307 | + | .classifier | |
| 308 | + | .set_action(at, part, request.payload.get(name).unwrap_or_default()); | |
| 309 | + | settled(state) | |
| 310 | + | } | |
| 311 | + | ||
| 312 | + | /// `POST /classifier/draft/test` | |
| 313 | + | fn test_draft(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> { | |
| 314 | + | state.classifier.test_draft(); | |
| 315 | + | settled(state) | |
| 316 | + | } | |
| 317 | + | ||
| 318 | + | /// `POST /classifier/draft/save` | |
| 319 | + | fn save_draft(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> { | |
| 320 | + | state.classifier.save_draft(); | |
| 321 | + | settled(state) | |
| 322 | + | } | |
| 323 | + | ||
| 324 | + | /// `POST /classifier/draft/cancel` | |
| 325 | + | fn cancel_draft(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> { | |
| 326 | + | state.classifier.cancel_draft(); | |
| 327 | + | settled(state) | |
| 328 | + | } | |
| 329 | + | ||
| 330 | + | // --- Auto-tagging ------------------------------------------------------------ | |
| 331 | + | ||
| 332 | + | /// `POST /classifier/autotag/suggest` | |
| 333 | + | fn suggest(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> { | |
| 334 | + | state.classifier.suggest(); | |
| 335 | + | settled(state) | |
| 336 | + | } | |
| 337 | + | ||
| 338 | + | /// `POST /classifier/undo/{source}` | |
| 339 | + | /// | |
| 340 | + | /// One route for the three undo controls, because they are one question with | |
| 341 | + | /// three answers: which pass is being taken back. The shipped screen had the | |
| 342 | + | /// same string in three places. | |
| 343 | + | fn undo_source(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> { | |
| 344 | + | let source = request.captures.require("source")?; | |
| 345 | + | if !matches!(source, "ml" | "cluster" | "harvest") { | |
| 346 | + | return Err(RouteError::not_found("nothing applied tags that way")); | |
| 347 | + | } | |
| 348 | + | state.classifier.undo_source(source); | |
| 349 | + | settled(state) | |
| 350 | + | } | |
| 351 | + | ||
| 352 | + | /// `POST /classifier/autotag/review` | |
| 353 | + | fn review(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> { | |
| 354 | + | state.classifier.review(); | |
| 355 | + | settled(state) | |
| 356 | + | } | |
| 357 | + | ||
| 358 | + | /// `POST /classifier/autotag/reopen` | |
| 359 | + | fn reopen(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> { | |
| 360 | + | if state.classifier.waiting() == 0 { | |
| 361 | + | return Err(RouteError::not_found("nothing is waiting")); | |
| 362 | + | } | |
| 363 | + | state.classifier.reopen_review(); | |
| 364 | + | settled(state) | |
| 365 | + | } | |
| 366 | + | ||
| 367 | + | /// `POST /classifier/head/train` | |
| 368 | + | fn train(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> { | |
| 369 | + | state.classifier.train(); | |
| 370 | + | settled(state) | |
| 371 | + | } | |
| 372 | + | ||
| 373 | + | /// `POST /classifier/head/clear` | |
| 374 | + | fn clear_head(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> { | |
| 375 | + | state.classifier.clear_head(); | |
| 376 | + | settled(state) | |
| 377 | + | } | |
| 378 | + | ||
| 379 | + | /// `POST /classifier/policies/{tag}/thresholds` | |
| 380 | + | /// | |
| 381 | + | /// Both ends in one call. They are two controls and one row, and a tag whose | |
| 382 | + | /// review threshold sat above its auto threshold for the length of a round trip | |
| 383 | + | /// would be a state the store should never see. | |
| 384 | + | fn set_policy(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> { | |
| 385 | + | let tag = request.captures.require("tag")?; | |
| 386 | + | let current = state | |
| 387 | + | .classifier | |
| 388 | + | .policies() | |
| 389 | + | .into_iter() | |
| 390 | + | .find(|policy| policy.tag == tag) | |
| 391 | + | .ok_or_else(|| RouteError::not_found("no threshold for that tag"))?; | |
| 392 | + | let review = number(&request, "review").unwrap_or(current.review); | |
| 393 | + | let auto = number(&request, "auto").unwrap_or(current.auto); | |
| 394 | + | state.classifier.set_policy(tag, review, auto); | |
| 395 | + | settled(state) | |
| 396 | + | } | |
| 397 | + | ||
| 398 | + | /// `POST /classifier/policies/typing` | |
| 399 | + | fn typing_policy(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> { | |
| 400 | + | state | |
| 401 | + | .classifier | |
| 402 | + | .typing_policy(request.payload.get("tag").unwrap_or_default()); | |
| 403 | + | settled(state) | |
| 404 | + | } | |
| 405 | + | ||
| 406 | + | /// `POST /classifier/policies/add` | |
| 407 | + | fn add_policy(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> { | |
| 408 | + | state.classifier.add_policy(); | |
| 409 | + | settled(state) | |
| 410 | + | } | |
| 411 | + | ||
| 412 | + | // --- Clusters and folders ---------------------------------------------------- | |
| 413 | + | ||
| 414 | + | /// `POST /classifier/clusters/k` | |
| 415 | + | fn cluster_k(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> { | |
| 416 | + | let asked = request | |
| 417 | + | .payload | |
| 418 | + | .get("cluster_k") | |
| 419 | + | .and_then(|value| value.parse().ok()) | |
| 420 | + | .ok_or_else(|| RouteError::not_found("that is not a number of groups"))?; | |
| 421 | + | state.classifier.set_cluster_k(asked); | |
| 422 | + | settled(state) | |
| 423 | + | } | |
| 424 | + | ||
| 425 | + | /// `POST /classifier/clusters/find` | |
| 426 | + | fn find_clusters(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> { | |
| 427 | + | state.classifier.find_clusters(); | |
| 428 | + | settled(state) | |
| 429 | + | } | |
| 430 | + | ||
| 431 | + | /// `POST /classifier/clusters/{at}/play` | |
| 432 | + | fn play_cluster(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> { | |
| 433 | + | state.classifier.play_cluster(slot(&request)?); | |
| 434 | + | settled(state) | |
| 435 | + | } | |
| 436 | + | ||
| 437 | + | /// `POST /classifier/clusters/{at}/name` | |
| 438 | + | fn name_cluster(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> { | |
| 439 | + | let at = slot(&request)?; | |
| 440 | + | state | |
| 441 | + | .classifier | |
| 442 | + | .name_cluster(at, request.payload.get("name").unwrap_or_default()); | |
| 443 | + | settled(state) | |
| 444 | + | } | |
| 445 | + | ||
| 446 | + | /// `POST /classifier/clusters/{at}/tag` | |
| 447 | + | fn tag_cluster(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> { | |
| 448 | + | state.classifier.tag_cluster(slot(&request)?); | |
| 449 | + | settled(state) | |
| 450 | + | } | |
| 451 | + | ||
| 452 | + | /// `POST /classifier/folders/scan` | |
| 453 | + | fn scan_folders(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> { | |
| 454 | + | state.classifier.scan_folders(); | |
| 455 | + | settled(state) | |
| 456 | + | } | |
| 457 | + | ||
| 458 | + | /// `POST /classifier/folders/{at}/tag` | |
| 459 | + | fn name_folder(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> { | |
| 460 | + | let at = slot(&request)?; | |
| 461 | + | state | |
| 462 | + | .classifier | |
| 463 | + | .name_folder(at, request.payload.get("tag").unwrap_or_default()); | |
| 464 | + | settled(state) | |
| 465 | + | } | |
| 466 | + | ||
| 467 | + | /// `POST /classifier/folders/{at}/apply` | |
| 468 | + | fn apply_folder(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> { | |
| 469 | + | state.classifier.apply_folder(slot(&request)?); | |
| 470 | + | settled(state) | |
| 471 | + | } | |
| 472 | + | ||
| 473 | + | // --- Sharing ----------------------------------------------------------------- | |
| 474 | + | ||
| 475 | + | /// `POST /classifier/sharing/name` | |
| 476 | + | fn name_export(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> { | |
| 477 | + | state | |
| 478 | + | .classifier | |
| 479 | + | .name_export(request.payload.get("export_name").unwrap_or_default()); | |
| 480 | + | settled(state) | |
| 481 | + | } | |
| 482 | + | ||
| 483 | + | /// `POST /classifier/sharing/include/{part}` | |
| 484 | + | fn include(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> { | |
| 485 | + | let name = request.captures.require("part")?; | |
| 486 | + | let part = match name { | |
| 487 | + | "exemplars" => Shareable::Exemplars, | |
| 488 | + | "rules" => Shareable::Rules, | |
| 489 | + | "thresholds" => Shareable::Thresholds, | |
| 490 | + | _ => return Err(RouteError::not_found("nothing of that name is shared")), | |
| 491 | + | }; | |
| 492 | + | state.classifier.include(part, ticked(&request, name)); | |
| 493 | + | settled(state) | |
| 494 | + | } | |
| 495 | + | ||
| 496 | + | /// `POST /classifier/sharing/export` | |
| 497 | + | fn export(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> { | |
| 498 | + | if !state.classifier.sharing().worth_writing() { | |
| 499 | + | return Err(RouteError::not_found("there is nothing to export")); | |
| 500 | + | } |
Lines truncated
| @@ -1,1432 +1,0 @@ | |||
| 1 | - | //! Tag-classifier UI: the deterministic rules builder (Layer A), drawn as a section | |
| 2 | - | //! inside the Settings window. Reads/mutates `state.classifier`; persistence and | |
| 3 | - | //! reconciliation go through the `classifier_*` methods on `BrowserState`. | |
| 4 | - | ||
| 5 | - | use egui; | |
| 6 | - | use makeover_immediate; | |
| 7 | - | use makeover_layout; | |
| 8 | - | ||
| 9 | - | use audiofiles_core::rules::{MatchMode, RuleAction, RuleCondition, RuleField, RuleOp}; | |
| 10 | - | ||
| 11 | - | use super::theme; | |
| 12 | - | use super::widgets; | |
| 13 | - | use crate::state::BrowserState; | |
| 14 | - | ||
| 15 | - | /// A confidence between never and only-on-certainty, as a described field. | |
| 16 | - | /// | |
| 17 | - | /// `makeover-layout` 0.28.0's `FieldKind::Range`. The two ends are the question | |
| 18 | - | /// here and not a rule the answer is checked against: 0 is never and 1 is | |
| 19 | - | /// only-on-certainty, and a typed 0.72 means nothing without both of them on | |
| 20 | - | /// screen. That is why these were the measured sites on the gap (`fb93426b`) | |
| 21 | - | /// and why they stayed hand-rolled until it closed. | |
| 22 | - | /// | |
| 23 | - | /// The value round-trips through a string because that is what the description | |
| 24 | - | /// carries and what every renderer of it takes: a webview reads a string out of | |
| 25 | - | /// the DOM and a terminal keeps an edit buffer, so a number would be this | |
| 26 | - | /// renderer's convenience imposed on the other two. Rebuilt per frame rather | |
| 27 | - | /// than held, which a slider allows and a text box does not: there is no | |
| 28 | - | /// half-typed state to lose. | |
| 29 | - | fn threshold_field( | |
| 30 | - | ui: &mut egui::Ui, | |
| 31 | - | name: &str, | |
| 32 | - | label: &str, | |
| 33 | - | value: &mut f32, | |
| 34 | - | ) -> Option<egui::Response> { | |
| 35 | - | let described = makeover_layout::Field { | |
| 36 | - | curve: makeover_layout::Curve::Linear { | |
| 37 | - | step: Some(THRESHOLD_STEP), | |
| 38 | - | }, | |
| 39 | - | ..makeover_layout::Field::range(name, label, "0", "1") | |
| 40 | - | }; | |
| 41 | - | let mut text = format!("{value:.2}"); | |
| 42 | - | let response = widgets::field( | |
| 43 | - | ui, | |
| 44 | - | &described, | |
| 45 | - | makeover_immediate::Filling::Text(&mut text), | |
| 46 | - | None, | |
| 47 | - | ); | |
| 48 | - | if let Ok(parsed) = text.parse::<f32>() { | |
| 49 | - | *value = parsed; | |
| 50 | - | } | |
| 51 | - | response | |
| 52 | - | } | |
| 53 | - | ||
| 54 | - | /// The granularity a confidence moves in. | |
| 55 | - | /// | |
| 56 | - | /// Two decimals, which is what the thresholds were already stored and shown at. | |
| 57 | - | /// Without it the host's own granularity stands, and egui's is continuous, so a | |
| 58 | - | /// drag would write back 0.7200000000000001 and the next comparison against a | |
| 59 | - | /// stored value would be against a number nobody chose. | |
| 60 | - | const THRESHOLD_STEP: &str = "0.01"; | |
| 61 | - | ||
| 62 | - | /// Fields offered in the condition builder (path/name + tags first, then DSP). | |
| 63 | - | const FIELDS: &[RuleField] = &[ | |
| 64 | - | RuleField::Name, | |
| 65 | - | RuleField::SourcePath, | |
| 66 | - | RuleField::VfsPath, | |
| 67 | - | RuleField::FileExtension, | |
| 68 | - | RuleField::Tag, | |
| 69 | - | RuleField::Duration, | |
| 70 | - | RuleField::Bpm, | |
| 71 | - | RuleField::MusicalKey, | |
| 72 | - | RuleField::IsLoop, | |
| 73 | - | RuleField::SpectralCentroid, | |
| 74 | - | RuleField::SpectralFlatness, | |
| 75 | - | RuleField::SpectralRolloff, | |
| 76 | - | RuleField::Zcr, | |
| 77 | - | RuleField::SpectralBandwidth, | |
| 78 | - | RuleField::CentroidVariance, | |
| 79 | - | RuleField::CrestFactor, | |
| 80 | - | RuleField::AttackTime, | |
| 81 | - | RuleField::PeakDb, | |
| 82 | - | RuleField::RmsDb, | |
| 83 | - | RuleField::Lufs, | |
| 84 | - | RuleField::SampleRate, | |
| 85 | - | RuleField::Channels, | |
| 86 | - | RuleField::FileSize, | |
| 87 | - | ]; | |
| 88 | - | ||
| 89 | - | fn field_label(f: RuleField) -> &'static str { | |
| 90 | - | use RuleField::{ | |
| 91 | - | AttackTime, Bpm, CentroidVariance, Channels, CrestFactor, Duration, FileExtension, | |
| 92 | - | FileSize, IsLoop, Lufs, MusicalKey, Name, PeakDb, RmsDb, SampleRate, SourcePath, | |
| 93 | - | SpectralBandwidth, SpectralCentroid, SpectralFlatness, SpectralRolloff, Tag, VfsPath, Zcr, | |
| 94 | - | }; | |
| 95 | - | match f { | |
| 96 | - | Name => "File name", | |
| 97 | - | SourcePath => "Source path", | |
| 98 | - | VfsPath => "Folder path", | |
| 99 | - | FileExtension => "Extension", | |
| 100 | - | Tag => "Tag", | |
| 101 | - | Duration => "Duration (s)", | |
| 102 | - | Bpm => "BPM", | |
| 103 | - | MusicalKey => "Key", | |
| 104 | - | IsLoop => "Is loop", | |
| 105 | - | SpectralCentroid => "Brightness", | |
| 106 | - | SpectralFlatness => "Noisiness", | |
| 107 | - | SpectralRolloff => "Rolloff", | |
| 108 | - | Zcr => "Zero-crossing rate", | |
| 109 | - | SpectralBandwidth => "Bandwidth", | |
| 110 | - | CentroidVariance => "Spectral motion", | |
| 111 | - | CrestFactor => "Crest factor", | |
| 112 | - | AttackTime => "Attack (s)", | |
| 113 | - | PeakDb => "Peak dB", | |
| 114 | - | RmsDb => "RMS dB", | |
| 115 | - | Lufs => "LUFS", | |
| 116 | - | SampleRate => "Sample rate", | |
| 117 | - | Channels => "Channels", | |
| 118 | - | FileSize => "File size", | |
| 119 | - | } | |
| 120 | - | } | |
| 121 | - | ||
| 122 | - | enum Kind { | |
| 123 | - | Text, | |
| 124 | - | Number, | |
| 125 | - | Bool, | |
| 126 | - | } | |
| 127 | - | ||
| 128 | - | fn field_kind(f: RuleField) -> Kind { | |
| 129 | - | use RuleField::{FileExtension, IsLoop, MusicalKey, Name, SourcePath, Tag, VfsPath}; | |
| 130 | - | match f { | |
| 131 | - | Name | SourcePath | VfsPath | FileExtension | Tag | MusicalKey => Kind::Text, | |
| 132 | - | IsLoop => Kind::Bool, | |
| 133 | - | _ => Kind::Number, | |
| 134 | - | } | |
| 135 | - | } | |
| 136 | - | ||
| 137 | - | const STRING_OPS: &[RuleOp] = &[ | |
| 138 | - | RuleOp::Contains, | |
| 139 | - | RuleOp::NotContains, | |
| 140 | - | RuleOp::Equals, | |
| 141 | - | RuleOp::NotEquals, | |
| 142 | - | RuleOp::StartsWith, | |
| 143 | - | RuleOp::EndsWith, | |
| 144 | - | RuleOp::Exists, | |
| 145 | - | RuleOp::NotExists, | |
| 146 | - | ]; | |
| 147 | - | const NUM_OPS: &[RuleOp] = &[ | |
| 148 | - | RuleOp::Lt, | |
| 149 | - | RuleOp::Le, | |
| 150 | - | RuleOp::Gt, | |
| 151 | - | RuleOp::Ge, | |
| 152 | - | RuleOp::Equals, | |
| 153 | - | RuleOp::NotEquals, | |
| 154 | - | RuleOp::Exists, | |
| 155 | - | RuleOp::NotExists, | |
| 156 | - | ]; | |
| 157 | - | const BOOL_OPS: &[RuleOp] = &[ | |
| 158 | - | RuleOp::IsTrue, | |
| 159 | - | RuleOp::IsFalse, | |
| 160 | - | RuleOp::Exists, | |
| 161 | - | RuleOp::NotExists, | |
| 162 | - | ]; | |
| 163 | - | ||
| 164 | - | fn ops_for(f: RuleField) -> &'static [RuleOp] { | |
| 165 | - | match field_kind(f) { | |
| 166 | - | Kind::Text => STRING_OPS, | |
| 167 | - | Kind::Number => NUM_OPS, | |
| 168 | - | Kind::Bool => BOOL_OPS, | |
| 169 | - | } | |
| 170 | - | } | |
| 171 | - | ||
| 172 | - | fn op_label(op: RuleOp) -> &'static str { | |
| 173 | - | use RuleOp::{ | |
| 174 | - | Contains, EndsWith, Equals, Exists, Ge, Gt, IsFalse, IsTrue, Le, Lt, NotContains, | |
| 175 | - | NotEquals, NotExists, StartsWith, | |
| 176 | - | }; | |
| 177 | - | match op { | |
| 178 | - | Contains => "contains", | |
| 179 | - | NotContains => "doesn't contain", | |
| 180 | - | Equals => "equals", | |
| 181 | - | NotEquals => "not equals", | |
| 182 | - | StartsWith => "starts with", | |
| 183 | - | EndsWith => "ends with", | |
| 184 | - | Lt => "<", | |
| 185 | - | Le => "\u{2264}", | |
| 186 | - | Gt => ">", | |
| 187 | - | Ge => "\u{2265}", | |
| 188 | - | IsTrue => "is true", | |
| 189 | - | IsFalse => "is false", | |
| 190 | - | Exists => "exists", | |
| 191 | - | NotExists => "missing", | |
| 192 | - | } | |
| 193 | - | } | |
| 194 | - | ||
| 195 | - | fn op_needs_value(op: RuleOp) -> bool { | |
| 196 | - | !matches!( | |
| 197 | - | op, | |
| 198 | - | RuleOp::Exists | RuleOp::NotExists | RuleOp::IsTrue | RuleOp::IsFalse | |
| 199 | - | ) | |
| 200 | - | } | |
| 201 | - | ||
| 202 | - | /// Keep a condition's operator valid for its field's kind: if `op` isn't one the | |
| 203 | - | /// field offers, fall back to the field's first offered op. `ops_for` is never empty, | |
| 204 | - | /// so `ops[0]` always exists. | |
| 205 | - | fn coerce_op(field: RuleField, op: RuleOp) -> RuleOp { | |
| 206 | - | let ops = ops_for(field); | |
| 207 | - | if ops.contains(&op) { op } else { ops[0] } | |
| 208 | - | } | |
| 209 | - | ||
| 210 | - | /// Draw all tag-classifier sections in Settings. | |
| 211 | - | pub fn draw_classifier_section(ui: &mut egui::Ui, state: &mut BrowserState) { | |
| 212 | - | // A background job (train/auto-tag/cluster/export) is running: show progress and let | |
| 213 | - | // the disabled buttons below explain why they're inert. Footer status is hidden behind | |
| 214 | - | // the Settings modal, so feedback lives here. | |
| 215 | - | if let Some(label) = state.classifier.busy.clone() { | |
| 216 | - | ui.horizontal(|ui| { | |
| 217 | - | ui.add(egui::Spinner::new().size(14.0)); | |
| 218 | - | ui.label( | |
| 219 | - | egui::RichText::new(label) | |
| 220 | - | .small() | |
| 221 | - | .color(theme::content_secondary()), | |
| 222 | - | ); | |
| 223 | - | }); | |
| 224 | - | ui.add_space(theme::space::bound()); | |
| 225 | - | } else if let Some(err) = state.classifier.last_error.clone() { | |
| 226 | - | widgets::warning_banner(ui, &err); | |
| 227 | - | ui.add_space(theme::space::bound()); | |
| 228 | - | } | |
| 229 | - | ||
| 230 | - | draw_rules_header(ui, state); | |
| 231 | - | ui.add_space(theme::space::bound()); | |
| 232 | - | draw_autotag_header(ui, state); | |
| 233 | - | ui.add_space(theme::space::bound()); | |
| 234 | - | draw_cluster_header(ui, state); | |
| 235 | - | ui.add_space(theme::space::bound()); | |
| 236 | - | draw_folder_header(ui, state); | |
| 237 | - | ui.add_space(theme::space::bound()); | |
| 238 | - | draw_sharing_header(ui, state); | |
| 239 | - | } | |
| 240 | - | ||
| 241 | - | /// "Tag Rules" section (Layer A). | |
| 242 | - | fn draw_rules_header(ui: &mut egui::Ui, state: &mut BrowserState) { | |
| 243 | - | egui::CollapsingHeader::new(egui::RichText::new("Tag Rules").strong()) | |
| 244 | - | .default_open(false) | |
| 245 | - | .show(ui, |ui| { | |
| 246 | - | state.ensure_rules_loaded(); | |
| 247 | - | ui.label( | |
| 248 | - | egui::RichText::new( | |
| 249 | - | "Deterministic rules that auto-apply tags by sample metadata and audio \ | |
| 250 | - | features. Rules never remove tags you added by hand.", | |
| 251 | - | ) | |
| 252 | - | .small() | |
| 253 | - | .color(theme::content_muted()), | |
| 254 | - | ); | |
| 255 | - | ui.add_space(theme::space::bound()); | |
| 256 | - | ||
| 257 | - | if state.classifier.editing.is_some() { | |
| 258 | - | draw_editor(ui, state); | |
| 259 | - | } else { | |
| 260 | - | draw_list(ui, state); | |
| 261 | - | } | |
| 262 | - | }); | |
| 263 | - | } | |
| 264 | - | ||
| 265 | - | fn draw_list(ui: &mut egui::Ui, state: &mut BrowserState) { | |
| 266 | - | ui.horizontal(|ui| { | |
| 267 | - | if ui.button("New rule").clicked() { | |
| 268 | - | state.classifier_new_draft(); | |
| 269 | - | } | |
| 270 | - | if ui | |
| 271 | - | .button("Apply rules now") | |
| 272 | - | .on_hover_text("Re-evaluate every rule across the whole library") | |
| 273 | - | .clicked() | |
| 274 | - | { | |
| 275 | - | state.classifier_apply_all(); | |
| 276 | - | } | |
| 277 | - | if ui | |
| 278 | - | .button("Add starter rules") | |
| 279 | - | .on_hover_text( | |
| 280 | - | "Add filename rules for the common instrument and format words \ | |
| 281 | - | (Kick.wav -> instrument.drum.kick). They arrive disabled: review \ | |
| 282 | - | them, then enable the ones you want.", | |
| 283 | - | ) | |
| 284 | - | .clicked() | |
| 285 | - | { | |
| 286 | - | state.classifier_seed_starter_rules(); | |
| 287 | - | } | |
| 288 | - | }); | |
| 289 | - | ||
| 290 | - | if let Some(n) = state.classifier.last_apply { | |
| 291 | - | ui.label( | |
| 292 | - | egui::RichText::new(format!( | |
| 293 | - | "Last apply: {n} sample{} updated", | |
| 294 | - | if n == 1 { "" } else { "s" } | |
| 295 | - | )) | |
| 296 | - | .small() | |
| 297 | - | .color(theme::content_muted()), | |
| 298 | - | ); | |
| 299 | - | } | |
| 300 | - | ui.add_space(theme::space::bound()); | |
| 301 | - | ||
| 302 | - | if state.classifier.rules.is_empty() { | |
| 303 | - | ui.label( | |
| 304 | - | egui::RichText::new( | |
| 305 | - | "No rules yet \u{2014} new rules start empty; you decide what gets tagged.", | |
| 306 | - | ) | |
| 307 | - | .small() | |
| 308 | - | .color(theme::content_muted()), | |
| 309 | - | ); | |
| 310 | - | return; | |
| 311 | - | } | |
| 312 | - | ||
| 313 | - | // Move the list out to iterate it, then move it back before applying the | |
| 314 | - | // deferred actions (a zero-copy swap replacing a per-frame deep clone of the | |
| 315 | - | // whole rules Vec). The loop body reads only the local list + `ui`, never | |
| 316 | - | // `state.classifier.rules`, so the temporary empty field is never observed. | |
| 317 | - | let rules = std::mem::take(&mut state.classifier.rules); | |
| 318 | - | let count = rules.len(); | |
| 319 | - | let mut toggle: Option<(String, bool)> = None; | |
| 320 | - | let mut edit: Option<usize> = None; | |
| 321 | - | let mut delete: Option<String> = None; | |
| 322 | - | let mut move_rule: Option<(usize, bool)> = None; | |
| 323 | - | ||
| 324 | - | for (i, rule) in rules.iter().enumerate() { | |
| 325 | - | ui.horizontal(|ui| { | |
| 326 | - | let mut enabled = rule.enabled; | |
| 327 | - | if ui.checkbox(&mut enabled, "").changed() { | |
| 328 | - | toggle = Some((rule.id.clone(), enabled)); | |
| 329 | - | } | |
| 330 | - | let name = if rule.name.trim().is_empty() { | |
| 331 | - | "(unnamed)" | |
| 332 | - | } else { | |
| 333 | - | rule.name.as_str() | |
| 334 | - | }; | |
| 335 | - | ui.label(name); | |
| 336 | - | ui.label( | |
| 337 | - | egui::RichText::new(format!( | |
| 338 | - | "{} cond \u{2192} {} act", | |
| 339 | - | rule.conditions.len(), | |
| 340 | - | rule.actions.len() | |
| 341 | - | )) | |
| 342 | - | .small() | |
| 343 | - | .color(theme::content_muted()), | |
| 344 | - | ); | |
| 345 | - | ||
| 346 | - | ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { | |
| 347 | - | if widgets::danger_small_button(ui, "Delete").clicked() { | |
| 348 | - | delete = Some(rule.id.clone()); | |
| 349 | - | } | |
| 350 | - | if ui.small_button("Edit").clicked() { | |
| 351 | - | edit = Some(i); | |
| 352 | - | } | |
| 353 | - | if ui | |
| 354 | - | .add_enabled(i + 1 < count, egui::Button::new("\u{25bc}").small()) | |
| 355 | - | .clicked() | |
| 356 | - | { | |
| 357 | - | move_rule = Some((i, false)); | |
| 358 | - | } | |
| 359 | - | if ui | |
| 360 | - | .add_enabled(i > 0, egui::Button::new("\u{25b2}").small()) | |
| 361 | - | .clicked() | |
| 362 | - | { | |
| 363 | - | move_rule = Some((i, true)); | |
| 364 | - | } | |
| 365 | - | }); | |
| 366 | - | }); | |
| 367 | - | } | |
| 368 | - | ||
| 369 | - | // Restore the list before applying deferred actions (which mutate it). | |
| 370 | - | state.classifier.rules = rules; | |
| 371 | - | ||
| 372 | - | if let Some((id, enabled)) = toggle { | |
| 373 | - | state.classifier_toggle_rule(&id, enabled); | |
| 374 | - | } | |
| 375 | - | if let Some(i) = edit { | |
| 376 | - | let rule = state.classifier.rules[i].clone(); | |
| 377 | - | state.classifier_edit_rule(&rule); | |
| 378 | - | } | |
| 379 | - | if let Some(id) = delete { | |
| 380 | - | state.classifier_delete_rule(&id); | |
| 381 | - | } | |
| 382 | - | if let Some((i, up)) = move_rule { | |
| 383 | - | state.classifier_move_rule(i, up); | |
| 384 | - | } | |
| 385 | - | } | |
| 386 | - | ||
| 387 | - | fn draw_editor(ui: &mut egui::Ui, state: &mut BrowserState) { | |
| 388 | - | let mut do_save = false; | |
| 389 | - | let mut do_cancel = false; | |
| 390 | - | let mut do_test = false; | |
| 391 | - | ||
| 392 | - | { | |
| 393 | - | let Some(draft) = state.classifier.editing.as_mut() else { | |
| 394 | - | return; | |
| 395 | - | }; | |
| 396 | - | ||
| 397 | - | // A described field: a labelled question the form refuses to submit | |
| 398 | - | // without, and the one input here that had no well at all — a bare | |
| 399 | - | // `text_edit_singleline` next to every other text input in the app | |
| 400 | - | // being a well. The error is `save_refused` rather than "empty" so a | |
| 401 | - | // freshly opened editor is not already complaining. | |
| 402 | - | let name_field = makeover_layout::Field { | |
| 403 | - | required: true, | |
| 404 | - | error: (draft.save_refused && draft.name.trim().is_empty()) | |
| 405 | - | .then_some("Name the rule before saving."), | |
| 406 | - | placeholder: Some("Kick drums"), | |
| 407 | - | ..makeover_layout::Field::new(makeover_layout::FieldKind::Text, "rule_name", "Name") | |
| 408 | - | }; | |
| 409 | - | if widgets::field( | |
| 410 | - | ui, | |
| 411 | - | &name_field, | |
| 412 | - | makeover_immediate::Filling::Text(&mut draft.name), | |
| 413 | - | None, | |
| 414 | - | ) | |
| 415 | - | .is_some_and(|response| response.changed()) | |
| 416 | - | { | |
| 417 | - | draft.save_refused = false; | |
| 418 | - | } | |
| 419 | - | ui.add_space(theme::space::bound()); | |
| 420 | - | // A bare toggle, not a field: no help, no validation, no options, and | |
| 421 | - | // under `field()` it would be a checkbox plus a required marker at | |
| 422 | - | // three times the source. | |
| 423 | - | ui.checkbox(&mut draft.enabled, "Enabled"); | |
| 424 | - | ||
| 425 | - | ui.horizontal(|ui| { | |
| 426 | - | ui.label("Match"); | |
| 427 | - | ui.selectable_value(&mut draft.match_mode, MatchMode::All, "All"); | |
| 428 | - | ui.selectable_value(&mut draft.match_mode, MatchMode::Any, "Any"); | |
| 429 | - | ui.label( | |
| 430 | - | egui::RichText::new("of these conditions") | |
| 431 | - | .small() | |
| 432 | - | .color(theme::content_muted()), | |
| 433 | - | ); | |
| 434 | - | }); | |
| 435 | - | ||
| 436 | - | // --- Conditions --- | |
| 437 | - | widgets::subsection_label(ui, "When"); | |
| 438 | - | let mut remove_cond: Option<usize> = None; | |
| 439 | - | let cond_count = draft.conditions.len(); | |
| 440 | - | for (i, cond) in draft.conditions.iter_mut().enumerate() { | |
| 441 | - | ui.horizontal(|ui| { | |
| 442 | - | egui::ComboBox::from_id_salt(("rule_field", i)) | |
| 443 | - | .selected_text(field_label(cond.field)) | |
| 444 | - | .width(120.0) | |
| 445 | - | .show_ui(ui, |ui| { | |
| 446 | - | for f in FIELDS { | |
| 447 | - | ui.selectable_value(&mut cond.field, *f, field_label(*f)); | |
| 448 | - | } | |
| 449 | - | }); | |
| 450 | - | // Keep the operator valid for the chosen field's kind. | |
| 451 | - | cond.op = coerce_op(cond.field, cond.op); | |
| 452 | - | let ops = ops_for(cond.field); | |
| 453 | - | egui::ComboBox::from_id_salt(("rule_op", i)) | |
| 454 | - | .selected_text(op_label(cond.op)) | |
| 455 | - | .width(110.0) | |
| 456 | - | .show_ui(ui, |ui| { | |
| 457 | - | for op in ops { | |
| 458 | - | ui.selectable_value(&mut cond.op, *op, op_label(*op)); | |
| 459 | - | } | |
| 460 | - | }); | |
| 461 | - | if op_needs_value(cond.op) { | |
| 462 | - | widgets::text_field( | |
| 463 | - | ui, | |
| 464 | - | egui::TextEdit::singleline(&mut cond.value) | |
| 465 | - | .desired_width(70.0) | |
| 466 | - | .hint_text("value"), | |
| 467 | - | ); | |
| 468 | - | } | |
| 469 | - | if ui | |
| 470 | - | .add_enabled(cond_count > 1, egui::Button::new("\u{2715}").small()) | |
| 471 | - | .clicked() | |
| 472 | - | { | |
| 473 | - | remove_cond = Some(i); | |
| 474 | - | } | |
| 475 | - | }); | |
| 476 | - | } | |
| 477 | - | if let Some(i) = remove_cond { | |
| 478 | - | draft.conditions.remove(i); | |
| 479 | - | draft.match_count = None; | |
| 480 | - | } | |
| 481 | - | if ui.small_button("Add condition").clicked() { | |
| 482 | - | draft.conditions.push(RuleCondition { | |
| 483 | - | field: RuleField::Name, | |
| 484 | - | op: RuleOp::Contains, | |
| 485 | - | value: String::new(), | |
| 486 | - | }); | |
| 487 | - | draft.match_count = None; | |
| 488 | - | } | |
| 489 | - | ||
| 490 | - | // --- Actions --- | |
| 491 | - | ui.add_space(theme::space::bound()); | |
| 492 | - | widgets::subsection_label(ui, "Then"); | |
| 493 | - | let mut remove_act: Option<usize> = None; | |
| 494 | - | for (i, action) in draft.actions.iter_mut().enumerate() { | |
| 495 | - | ui.horizontal(|ui| { | |
| 496 | - | // 0 = add tag, 1 = remove tag, 2 = stop. | |
| 497 | - | let mut kind = match action { | |
| 498 | - | RuleAction::AddTag(_) => 0u8, | |
| 499 | - | RuleAction::RemoveTag(_) => 1, | |
| 500 | - | RuleAction::Stop => 2, |
Lines truncated