1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
//! **The tuning pane between frames**, and the four acts its controls spend.
//!
//! Its type and its acts are one file rather than the two the enrollment uses
//! (`crate::ui::model::enroll` beside `crate::ui::model::acts`), because
//! `acts.rs` is at its design-time budget and because these four acts have no
//! reader but this type. The rule that governs both placements is the same one:
//! **a binding names a control that already exists**, so what a click means
//! lives once, wherever it lives.
//!
//! # The pane is a two-state thing, not a flag beside an option
//!
//! It is open showing rows, or open with one role's assignment being rewritten.
//! Spelling that as `open: bool` beside `editing: Option<Edit>` would admit a
//! fourth state that means nothing — editing while closed — and a state that
//! means nothing is a state some frame eventually paints. [`Tuning`] has the
//! two the pane has.
//!
//! # What is NOT held here is the answer
//!
//! The rows come off the wire into [`Model::roles`] and are the server's, not
//! the pane's. **That field is one option where the conversation list needs a
//! pair**, and the difference is real: an empty `Vec` is a workspace whose config
//! declares no role — a state a fresh workspace is really in — and `None` is a
//! workspace nobody has been answered about. The list next door needs `answered`
//! beside it only because it also carries rows the server never sent.
//!
//! The read is standing while the pane is open
//! (`crate::state::Standing`), so a tuning act that lands is reflected by the
//! next answer rather than by this end predicting one. A seat that wrote its
//! own row back would be painting a claim the server had not made — and the
//! three writes go through `litany config`, which can refuse.
//!
//! The one thing held is the **draft** of an assignment, because two words
//! typed into two boxes are not a fact about anything until they are sent.
use super::{Aim, Model};
use crate::reply::roles::RoleRow;
/// The tuning pane, while it is open.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Tuning {
/// The rows, each with its own controls.
Rows,
/// One role's assignment being rewritten, under the row it belongs to.
Editing(Edit),
}
/// **An assignment being rewritten**: which role, and the two words for it.
///
/// It is seeded from the row it was opened on, so the boxes open holding what
/// is in force — the whole point of the `roles` read being the pane's first
/// question — and an operator changing one word does not have to retype the
/// other.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Edit {
/// The role, which is the address the write takes and is never edited: a
/// role the config does not declare is refused, so a box for it would be a
/// box whose only other value is a refusal.
pub role: String,
pub provider: String,
pub model: String,
}
impl Edit {
/// Seed one from the row it was opened on.
pub fn of(row: &RoleRow) -> Self {
Self {
role: row.role.clone(),
provider: row.provider.clone(),
model: row.model.clone(),
}
}
/// **Whether it names both halves of an assignment.** A provider with no
/// model and a model on no provider are each half a fact, and the write
/// takes the pair.
pub fn ready(&self) -> bool {
!self.provider.trim().is_empty() && !self.model.trim().is_empty()
}
}
impl Model {
/// **Open the tuning pane on the workspace the window is aimed at**, or do
/// nothing where it is aimed at none.
///
/// The aim is the gate rather than a separate check, exactly as it is for
/// the enrollment: every gesture the pane composes carries a workspace, and
/// a workspace is what an aim is.
pub fn begin_tuning(&mut self) {
if self.aim.is_some() {
self.tuning = Some(Tuning::Rows);
}
}
/// **Close it**, dropping any draft with it. Nothing else is touched — the
/// rows stay, because the next open on the same workspace is about the same
/// rows and the standing read replaces them anyway.
pub fn close_tuning(&mut self) {
self.tuning = None;
}
/// **Begin rewriting one role's assignment**, seeded from what is in force.
pub fn edit_assignment(&mut self, row: &RoleRow) {
if self.tuning.is_some() {
self.tuning = Some(Tuning::Editing(Edit::of(row)));
}
}
/// **Put the draft down** and go back to the rows. It is not the same act
/// as closing the pane, which is why it is not the same control.
pub fn cancel_assignment(&mut self) {
if self.tuning.is_some() {
self.tuning = Some(Tuning::Rows);
}
}
/// **The draft, if one is open.** The pane paints the editor under the row
/// it belongs to, so it asks by role rather than for the whole state.
pub fn editing(&self, role: &str) -> Option<Edit> {
match &self.tuning {
Some(Tuning::Editing(edit)) if edit.role == role => Some(edit.clone()),
_ => None,
}
}
/// **Take the draft for a box to type into**, where one is open. The pane
/// needs a place to write and the model is the only one there is.
pub fn draft_assignment(&mut self) -> Option<&mut Edit> {
match &mut self.tuning {
Some(Tuning::Editing(edit)) => Some(edit),
_ => None,
}
}
/// **Ask this role's model calls for `level` much reasoning**, or for none.
pub fn post_effort(&mut self, role: &str, level: Option<String>) {
self.tune(&|address| crate::verbs::effort(address, role.to_owned(), level.clone()));
}
/// **Turn this role's priority lane on or off.**
pub fn post_priority(&mut self, role: &str, on: bool) {
self.tune(&|address| crate::verbs::priority(address, role.to_owned(), on));
}
/// **Spend the draft assignment**, and go back to the rows.
///
/// It composes and then closes the editor, unlike the enrollment's own
/// post: an enrollment has to stand until its answer arrives because the
/// answer *is* the product, and this act's product is a row the standing
/// read will bring back on its own.
pub fn post_assignment(&mut self) {
let Some(edit) = self.draft_assignment().filter(|edit| edit.ready()).cloned() else {
return;
};
self.post_model(
&edit.role,
edit.provider.trim().to_owned(),
edit.model.trim().to_owned(),
);
self.cancel_assignment();
}
/// **Bind this role to `model` on the `provider` row.**
pub fn post_model(&mut self, role: &str, provider: String, model: String) {
self.tune(&|address| {
crate::verbs::model(address, role.to_owned(), provider.clone(), model.clone())
});
}
/// **Compose one tuning gesture against the aimed workspace**, or none at all.
///
/// The one door the three writes share, because all three carry the same
/// address and the aim is the same gate for each: a pane open with nothing
/// aimed at is a state [`Model::begin_tuning`] refuses to create, and this
/// is the reading that makes it unreachable rather than merely unlikely.
///
/// **`&dyn Fn` rather than a generic**, which is the house preference for a
/// door taking behaviour and is also the honest shape here: a bound would
/// stamp out one copy of this body per call site, and the arm that costs
/// nothing to run is then three arms a suite has to reach separately to say
/// the same thing once.
fn tune(&mut self, gesture: &dyn Fn(String) -> serde_json::Value) {
let Some(aim) = self.aim.clone() else {
return;
};
let Aim { address, .. } = aim;
self.outbox.push(super::Posted::act(gesture(address)));
}
}
mod offers;
#[cfg(test)]
mod tests;