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
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
//! The modal layer: a question or a long output that owns the keyboard.
//!
//! One modal shows at a time and a later one waits behind it, so a chain of
//! dependencies can ask one dog after another. The owner picks a `tag` and
//! reads it back from [`Effect::ModalAnswered`]; nothing here knows what a
//! yes does.
use super::*;
use crate::commands::dogs::Prompt;
use crate::terminal_safe::{sanitise, sanitise_multiline};
/// The most bytes of an output modal's text that are shown.
pub(in crate::lookout) const OUTPUT_CAP: usize = 4096;
/// What ends a text cut at [`OUTPUT_CAP`].
pub(in crate::lookout) const CUT_MARK: &str = "... (cut)";
/// Text a modal shows, already free of control characters and capped.
///
/// The only way to build one is [`Shown::of`], so a dog's or cargo's output
/// cannot reach a modal's body with an escape in it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct Shown(String);
impl Shown {
/// `text` with control and invisible characters removed and a newline
/// kept, then cut to [`OUTPUT_CAP`] bytes on a character boundary.
fn of(text: &str) -> Self {
let (mut clean, _) = sanitise_multiline(text);
if clean.len() > OUTPUT_CAP {
let mut end = OUTPUT_CAP;
while !clean.is_char_boundary(end) {
end -= 1;
}
clean.truncate(end);
clean.push_str(CUT_MARK);
}
Self(clean)
}
/// The text to draw.
pub(crate) fn as_str(&self) -> &str {
&self.0
}
}
/// One thing the modal layer asks or shows.
///
/// Every kind carries the `tag` its owner chose, which comes back on the
/// answer. `Debug` is derived: a grant prompt is manifest text the operator
/// is about to review, and an output modal holds output already cleaned.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum Modal {
/// The grant prompt `shep adopt` prints, asked as yes or no.
Grant {
/// The owner's tag for this question.
tag: u64,
/// What is listed and asked.
prompt: Prompt,
},
/// Long output, such as a failed install's, with a title.
Output {
/// The owner's tag.
tag: u64,
/// What the output is of, on one line.
title: Shown,
/// The output, capped.
text: Shown,
},
/// One yes or no question.
Confirm {
/// The owner's tag for this question.
tag: u64,
/// The question, without keys.
question: String,
},
}
impl Modal {
/// A grant prompt for `prompt`.
pub(crate) fn grant(tag: u64, prompt: Prompt) -> Self {
Self::Grant { tag, prompt }
}
/// `text` under `title`, with escapes removed and the text capped at
/// [`OUTPUT_CAP`] bytes.
pub(crate) fn output(tag: u64, title: &str, text: &str) -> Self {
let (title, _) = sanitise(title);
Self::Output {
tag,
title: Shown(title),
text: Shown::of(text),
}
}
/// A yes or no `question`.
pub(crate) fn confirm(tag: u64, question: String) -> Self {
Self::Confirm { tag, question }
}
/// The owner's tag.
pub(crate) fn tag(&self) -> u64 {
match self {
Self::Grant { tag, .. } | Self::Output { tag, .. } | Self::Confirm { tag, .. } => *tag,
}
}
}
/// The open modal, the ones waiting, and how far the open one is scrolled.
#[derive(Debug, Default)]
pub(super) struct Modals {
queue: VecDeque<Modal>,
scroll: usize,
/// How many lines the open modal's box cannot show, or `None` while it is
/// not on screen: not drawn yet, or the terminal too small to draw it.
reach: Option<usize>,
}
impl App {
/// Opens `modal`, or queues it behind the one already open.
pub(crate) fn open_modal(&mut self, modal: Modal) {
self.modals.queue.push_back(modal);
}
/// Drops the modal `tag` asked, open or waiting, because its question no
/// longer has anything to act on.
pub(super) fn retire_modal(&mut self, tag: u64) {
if self
.modals
.queue
.front()
.is_some_and(|modal| modal.tag() == tag)
{
self.close_modal();
} else {
self.modals.queue.retain(|modal| modal.tag() != tag);
}
}
/// The modal that has the keyboard, or `None`.
pub(crate) fn modal(&self) -> Option<&Modal> {
self.modals.queue.front()
}
/// How many lines the open modal's text is scrolled.
pub(crate) fn modal_scroll(&self) -> usize {
self.modals.scroll
}
/// Tells the layer how many lines of the open modal's text the box cannot
/// show, so scrolling stops at the last line, or `None` when the modal is
/// not on screen. The event loop calls it before each draw, as it does
/// [`Self::note_body_rows`].
pub(crate) fn note_modal_reach(&mut self, reach: Option<usize>) {
self.modals.reach = reach;
self.modals.scroll = self.modals.scroll.min(reach.unwrap_or(0));
}
/// One key while a modal is open, ahead of every body and text box.
///
/// Enter answers a grant or a confirm yes and any other key no. With the
/// text taller than the box the four select keys scroll it instead, so a
/// long permission list can be read. Output closes on every other key.
// A modal not on screen answers nothing, since no one has seen the
// question: it waits for its first draw or for a resize. Quit still
// quits, as it does on the too-small screen.
pub(super) fn on_modal_key(&mut self, key: KeyPress) -> Effect {
let Some(modal) = self.modals.queue.front() else {
return Effect::None;
};
let Some(reach) = self.modals.reach else {
return if key == KeyPress::Quit {
Effect::Quit
} else {
Effect::None
};
};
let tag = modal.tag();
let asks = !matches!(modal, Modal::Output { .. });
if reach > 0 && self.scroll_modal(key) {
return Effect::None;
}
self.close_modal();
if !asks {
return Effect::None;
}
let yes = matches!(key, KeyPress::Confirm | KeyPress::TextApply);
Effect::ModalAnswered { tag, yes }
}
/// Whether the open modal's text is taller than its box, so the select
/// keys scroll it.
pub(crate) fn modal_scrolls(&self) -> bool {
self.modals.reach.is_some_and(|reach| reach > 0)
}
/// Moves the scroll for a select key, or returns `false` for any other.
fn scroll_modal(&mut self, key: KeyPress) -> bool {
let modals = &mut self.modals;
let reach = modals.reach.unwrap_or(0);
match key {
KeyPress::SelectUp => modals.scroll = modals.scroll.saturating_sub(1),
KeyPress::SelectDown => modals.scroll = (modals.scroll + 1).min(reach),
KeyPress::SelectFirst => modals.scroll = 0,
KeyPress::SelectLast => modals.scroll = reach,
_ => return false,
}
true
}
/// Drops the open modal; the next one, if any, starts at the top and is
/// not on screen until it is drawn.
fn close_modal(&mut self) {
self.modals.queue.pop_front();
self.modals.scroll = 0;
self.modals.reach = None;
}
}
#[cfg(test)]
mod tests;