ppicker 0.2.0__tar.gz → 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. {ppicker-0.2.0 → ppicker-0.3.0}/Cargo.lock +1 -1
  2. {ppicker-0.2.0 → ppicker-0.3.0}/Cargo.toml +1 -1
  3. {ppicker-0.2.0 → ppicker-0.3.0}/PKG-INFO +20 -2
  4. {ppicker-0.2.0 → ppicker-0.3.0}/README.md +19 -1
  5. ppicker-0.3.0/assets/09-stay-open.png +0 -0
  6. {ppicker-0.2.0 → ppicker-0.3.0}/e2e/scenario.sh +17 -0
  7. {ppicker-0.2.0 → ppicker-0.3.0}/src/main.rs +22 -8
  8. {ppicker-0.2.0 → ppicker-0.3.0}/src/store.rs +4 -0
  9. {ppicker-0.2.0 → ppicker-0.3.0}/src/tui.rs +371 -120
  10. {ppicker-0.2.0 → ppicker-0.3.0}/.gitattributes +0 -0
  11. {ppicker-0.2.0 → ppicker-0.3.0}/.github/workflows/ci.yml +0 -0
  12. {ppicker-0.2.0 → ppicker-0.3.0}/.github/workflows/crates.yml +0 -0
  13. {ppicker-0.2.0 → ppicker-0.3.0}/.github/workflows/release.yml +0 -0
  14. {ppicker-0.2.0 → ppicker-0.3.0}/.gitignore +0 -0
  15. {ppicker-0.2.0 → ppicker-0.3.0}/assets/01-search.png +0 -0
  16. {ppicker-0.2.0 → ppicker-0.3.0}/assets/02-open-with.png +0 -0
  17. {ppicker-0.2.0 → ppicker-0.3.0}/assets/03-remembered.png +0 -0
  18. {ppicker-0.2.0 → ppicker-0.3.0}/assets/04-switch-editor.png +0 -0
  19. {ppicker-0.2.0 → ppicker-0.3.0}/assets/05-rofi-project.png +0 -0
  20. {ppicker-0.2.0 → ppicker-0.3.0}/assets/06-rofi-editor.png +0 -0
  21. {ppicker-0.2.0 → ppicker-0.3.0}/assets/07-workspace.png +0 -0
  22. {ppicker-0.2.0 → ppicker-0.3.0}/assets/08-workspace-open-with.png +0 -0
  23. {ppicker-0.2.0 → ppicker-0.3.0}/assets/hero.png +0 -0
  24. {ppicker-0.2.0 → ppicker-0.3.0}/docs/MIGRATION.md +0 -0
  25. {ppicker-0.2.0 → ppicker-0.3.0}/e2e/Dockerfile +0 -0
  26. {ppicker-0.2.0 → ppicker-0.3.0}/e2e/Dockerfile.dockerignore +0 -0
  27. {ppicker-0.2.0 → ppicker-0.3.0}/e2e/README.md +0 -0
  28. {ppicker-0.2.0 → ppicker-0.3.0}/e2e/i3config +0 -0
  29. {ppicker-0.2.0 → ppicker-0.3.0}/pyproject.toml +0 -0
  30. {ppicker-0.2.0 → ppicker-0.3.0}/src/editors.rs +0 -0
  31. {ppicker-0.2.0 → ppicker-0.3.0}/src/exec.rs +0 -0
  32. {ppicker-0.2.0 → ppicker-0.3.0}/src/menu.rs +0 -0
@@ -1097,7 +1097,7 @@ checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958"
1097
1097
 
1098
1098
  [[package]]
1099
1099
  name = "ppicker"
1100
- version = "0.2.0"
1100
+ version = "0.3.0"
1101
1101
  dependencies = [
1102
1102
  "anyhow",
1103
1103
  "clap",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "ppicker"
3
- version = "0.2.0"
3
+ version = "0.3.0"
4
4
  edition = "2024"
5
5
  description = "Fast project picker for tiling window managers: open a project in your editor"
6
6
  license = "MIT"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ppicker
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Classifier: Development Status :: 4 - Beta
5
5
  Classifier: Environment :: Console
6
6
  Classifier: Programming Language :: Rust
@@ -72,6 +72,19 @@ editor will be used, when you last opened it, and its tags. Press `Tab` to filte
72
72
  Press **Enter**. The project opens in its editor and ppicker closes. The editor is started in
73
73
  the project's folder and keeps running on its own, so closing the terminal does not close it.
74
74
 
75
+ ### Open several projects at once
76
+
77
+ Press **Ctrl-o** instead of Enter. The project opens just the same, but ppicker **stays open**, so
78
+ you can go straight on to the next one: type, `Ctrl-o`, type, `Ctrl-o`, then `Esc` when you are
79
+ done. A line at the bottom confirms each one (`opened web-ui in pycharm`), and the list updates
80
+ the editor and "last used" time as you go.
81
+
82
+ ![Two projects opened in one session; the picker is still open](https://raw.githubusercontent.com/roman-right/ppicker/main/assets/09-stay-open.png)
83
+
84
+ Prefer it the other way round? Set `keep_open = true` in the config: then `Enter` opens and stays,
85
+ and `Ctrl-o` opens and quits. If an editor cannot be started (say, its command is not installed),
86
+ ppicker stays open and shows the error instead of closing.
87
+
75
88
  The first time you open a project, it uses your default editor, the first one in your config
76
89
  (see [Editors](https://github.com/roman-right/ppicker#editors)). After that it uses whichever editor you opened it with last.
77
90
 
@@ -155,7 +168,8 @@ For other launchers, pass the command to use: `pp menu --cmd "fuzzel --dmenu"` (
155
168
  |---|---|
156
169
  | type | search by name or path |
157
170
  | `↑` `↓` (or `Ctrl-p` `Ctrl-n`) | move through the list |
158
- | `Enter` | open the project in its editor |
171
+ | `Enter` | open the project in its editor, then close |
172
+ | `Ctrl-o` | open the project and **keep the picker open** (to open several) |
159
173
  | `→` | open with a different editor |
160
174
  | `1`-`9` | in the editor list, open with that editor |
161
175
  | `Ctrl-e` | switch the project's editor without opening it |
@@ -163,6 +177,9 @@ For other launchers, pass the command to use: `pp menu --cmd "fuzzel --dmenu"` (
163
177
  | `Ctrl-u` | clear the search |
164
178
  | `←` or `Esc` | back from the editor list; `Esc` in the main list quits |
165
179
 
180
+ The `Ctrl` shortcuts also work when a Russian keyboard layout is active (`Ctrl-щ` is `Ctrl-o`).
181
+ With `keep_open = true` in the config, `Enter` and `Ctrl-o` swap roles.
182
+
166
183
  ## Editors
167
184
 
168
185
  List the editors you use in the config file (run `pp paths` to see where it is). The first one is
@@ -171,6 +188,7 @@ the default for projects you have not opened yet:
171
188
  ```toml
172
189
  ides = ["vscode", "pycharm", "zed"]
173
190
  scan_dirs = ["/home/me/code"] # used by `pp scan` when you give no folder
191
+ keep_open = false # true: Enter opens and stays open, Ctrl-o opens and quits
174
192
  ```
175
193
 
176
194
  Each entry is either a **name** ppicker knows, or a plain **command**.
@@ -57,6 +57,19 @@ editor will be used, when you last opened it, and its tags. Press `Tab` to filte
57
57
  Press **Enter**. The project opens in its editor and ppicker closes. The editor is started in
58
58
  the project's folder and keeps running on its own, so closing the terminal does not close it.
59
59
 
60
+ ### Open several projects at once
61
+
62
+ Press **Ctrl-o** instead of Enter. The project opens just the same, but ppicker **stays open**, so
63
+ you can go straight on to the next one: type, `Ctrl-o`, type, `Ctrl-o`, then `Esc` when you are
64
+ done. A line at the bottom confirms each one (`opened web-ui in pycharm`), and the list updates
65
+ the editor and "last used" time as you go.
66
+
67
+ ![Two projects opened in one session; the picker is still open](https://raw.githubusercontent.com/roman-right/ppicker/main/assets/09-stay-open.png)
68
+
69
+ Prefer it the other way round? Set `keep_open = true` in the config: then `Enter` opens and stays,
70
+ and `Ctrl-o` opens and quits. If an editor cannot be started (say, its command is not installed),
71
+ ppicker stays open and shows the error instead of closing.
72
+
60
73
  The first time you open a project, it uses your default editor, the first one in your config
61
74
  (see [Editors](https://github.com/roman-right/ppicker#editors)). After that it uses whichever editor you opened it with last.
62
75
 
@@ -140,7 +153,8 @@ For other launchers, pass the command to use: `pp menu --cmd "fuzzel --dmenu"` (
140
153
  |---|---|
141
154
  | type | search by name or path |
142
155
  | `↑` `↓` (or `Ctrl-p` `Ctrl-n`) | move through the list |
143
- | `Enter` | open the project in its editor |
156
+ | `Enter` | open the project in its editor, then close |
157
+ | `Ctrl-o` | open the project and **keep the picker open** (to open several) |
144
158
  | `→` | open with a different editor |
145
159
  | `1`-`9` | in the editor list, open with that editor |
146
160
  | `Ctrl-e` | switch the project's editor without opening it |
@@ -148,6 +162,9 @@ For other launchers, pass the command to use: `pp menu --cmd "fuzzel --dmenu"` (
148
162
  | `Ctrl-u` | clear the search |
149
163
  | `←` or `Esc` | back from the editor list; `Esc` in the main list quits |
150
164
 
165
+ The `Ctrl` shortcuts also work when a Russian keyboard layout is active (`Ctrl-щ` is `Ctrl-o`).
166
+ With `keep_open = true` in the config, `Enter` and `Ctrl-o` swap roles.
167
+
151
168
  ## Editors
152
169
 
153
170
  List the editors you use in the config file (run `pp paths` to see where it is). The first one is
@@ -156,6 +173,7 @@ the default for projects you have not opened yet:
156
173
  ```toml
157
174
  ides = ["vscode", "pycharm", "zed"]
158
175
  scan_dirs = ["/home/me/code"] # used by `pp scan` when you give no folder
176
+ keep_open = false # true: Enter opens and stays open, Ctrl-o opens and quits
159
177
  ```
160
178
 
161
179
  Each entry is either a **name** ppicker knows, or a plain **command**.
Binary file
@@ -122,6 +122,23 @@ $PP editor "Shop Front" --reset >/dev/null
122
122
  check "no editor can open it: error says what to add" "$PP open 'Shop Front' 2>&1 | grep -q 'VS Code.*vscode'"
123
123
  printf 'ides = ["code", "pycharm", "vim"]\n' > ~/.config/ppicker/config.toml
124
124
 
125
+ echo "=== 8. Ctrl-O opens several projects without closing the picker ==="
126
+ reset_calls
127
+ popup
128
+ xdotool type --delay 120 "api"; sleep 1
129
+ xdotool key ctrl+o; sleep 1.5
130
+ check "picker is still open after Ctrl-O" "pgrep -x pp >/dev/null"
131
+ check "first project opened (api-server, in code)" "[ \"\$(called code)\" = /root/code/api-server ]"
132
+ xdotool key ctrl+u; sleep 0.3
133
+ xdotool type --delay 120 "web"; sleep 1
134
+ xdotool key ctrl+o; sleep 1.5
135
+ shot 9-stay-open # status line: "opened web-ui in pycharm"
136
+ check "second project opened too (web-ui, in pycharm)" "[ \"\$(called pycharm)\" = /root/code/web-ui ]"
137
+ check "picker still open after the second" "pgrep -x pp >/dev/null"
138
+ xdotool key Escape; sleep 1.2
139
+ check "Esc then closes it" "! pgrep -x pp >/dev/null"
140
+ pkill alacritty 2>/dev/null; sleep 1
141
+
125
142
  echo "=== overview: every project, each with the editor it was last opened in ==="
126
143
  popup
127
144
  shot 0-overview
@@ -106,15 +106,11 @@ fn real_main() -> Result<()> {
106
106
  store.sort_recent();
107
107
  let tags = store.all_tags();
108
108
  let projects = store.projects.clone();
109
- // Editor changes made in the picker are saved the moment they happen.
110
- let on_ide_change = Box::new(|path: &Path, ide: Option<&str>| {
111
- store.set_ide(path, ide.map(String::from));
112
- let _ = store.save();
109
+ let host = Box::new(PickerHost {
110
+ store: &mut store,
111
+ cfg: &cfg,
113
112
  });
114
- let picked = tui::pick(projects, tags, &cfg, on_ide_change)?;
115
- if let Some(p) = picked {
116
- launch(&mut store, &cfg, &p.project, &p.ide)?;
117
- }
113
+ tui::pick(projects, tags, &cfg, host)?;
118
114
  }
119
115
  Some(Cmd::Add {
120
116
  path,
@@ -297,6 +293,24 @@ fn real_main() -> Result<()> {
297
293
  Ok(())
298
294
  }
299
295
 
296
+ /// What the picker uses to start editors and save changes while it runs.
297
+ struct PickerHost<'a> {
298
+ store: &'a mut Store,
299
+ cfg: &'a Config,
300
+ }
301
+
302
+ impl tui::Host for PickerHost<'_> {
303
+ /// Saved the moment it changes, so quitting any way loses nothing.
304
+ fn ide_changed(&mut self, path: &Path, ide: Option<&str>) {
305
+ self.store.set_ide(path, ide.map(String::from));
306
+ let _ = self.store.save();
307
+ }
308
+
309
+ fn open(&mut self, project: &Project, ide: &str) -> Result<()> {
310
+ launch(self.store, self.cfg, project, ide)
311
+ }
312
+ }
313
+
300
314
  /// Start the editor first, and only remember it (and the open time) if that
301
315
  /// worked, so a typo'd or missing command is never saved as the project's editor.
302
316
  fn launch(store: &mut Store, cfg: &Config, project: &Project, ide: &str) -> Result<()> {
@@ -237,6 +237,9 @@ pub struct Config {
237
237
  /// Overrides for what a name runs: `vscode = ["code-insiders", "code"]`
238
238
  /// (the first command found on PATH is used). Can also define new names.
239
239
  pub commands: HashMap<String, Vec<String>>,
240
+ /// If true, `Enter` opens a project and leaves the picker open (and
241
+ /// `Ctrl-o` opens and quits). By default it is the other way round.
242
+ pub keep_open: bool,
240
243
  }
241
244
 
242
245
  impl Default for Config {
@@ -245,6 +248,7 @@ impl Default for Config {
245
248
  ides: vec!["code".into()],
246
249
  scan_dirs: vec![],
247
250
  commands: HashMap::new(),
251
+ keep_open: false,
248
252
  }
249
253
  }
250
254
  }
@@ -27,15 +27,17 @@ enum Stage {
27
27
  Editors,
28
28
  }
29
29
 
30
- /// What the user chose: open `project` with the editor command `ide`.
31
- pub struct Picked {
32
- pub project: Project,
33
- pub ide: String,
30
+ /// What the picker asks of the program around it: starting editors and
31
+ /// saving changes. The picker calls these while it is running, so it can stay
32
+ /// open after launching something.
33
+ pub trait Host {
34
+ /// A project's remembered editor changed (`None` = the global default).
35
+ fn ide_changed(&mut self, path: &Path, ide: Option<&str>);
36
+ /// Start `ide` on `project`. An error (say, the command is not installed)
37
+ /// is shown in the picker instead of closing it.
38
+ fn open(&mut self, project: &Project, ide: &str) -> Result<()>;
34
39
  }
35
40
 
36
- /// Receives `(project path, remembered editor)` each time the editor changes.
37
- type IdeChanged<'a> = Box<dyn FnMut(&Path, Option<&str>) + 'a>;
38
-
39
41
  struct App<'a> {
40
42
  cfg: &'a Config,
41
43
  projects: Vec<Project>,
@@ -50,9 +52,9 @@ struct App<'a> {
50
52
  editors: Vec<String>,
51
53
  editor_list: ListState,
52
54
  matcher: Matcher,
53
- done: Option<Option<Picked>>,
54
- /// Called whenever a project's editor changes, so it is saved at once.
55
- on_ide_change: IdeChanged<'a>,
55
+ /// Set when the picker should close.
56
+ done: bool,
57
+ host: Box<dyn Host + 'a>,
56
58
  /// One-line feedback shown in place of the key hints until the next key.
57
59
  status: Option<String>,
58
60
  }
@@ -62,7 +64,7 @@ impl<'a> App<'a> {
62
64
  projects: Vec<Project>,
63
65
  tags: Vec<String>,
64
66
  cfg: &'a Config,
65
- on_ide_change: IdeChanged<'a>,
67
+ host: Box<dyn Host + 'a>,
66
68
  ) -> Self {
67
69
  let mut app = Self {
68
70
  cfg,
@@ -76,8 +78,8 @@ impl<'a> App<'a> {
76
78
  editors: vec![],
77
79
  editor_list: ListState::default(),
78
80
  matcher: Matcher::new(MatcherConfig::DEFAULT.match_paths()),
79
- done: None,
80
- on_ide_change,
81
+ done: false,
82
+ host,
81
83
  status: None,
82
84
  };
83
85
  app.refilter();
@@ -157,20 +159,48 @@ impl<'a> App<'a> {
157
159
  self.cfg.effective_ide(p)
158
160
  }
159
161
 
160
- /// Open the selected project in its own editor (Enter).
161
- fn open_current(&mut self) {
162
+ /// Whether the picker stays open after opening. `Enter` follows the
163
+ /// `keep_open` setting; the alternate key (`Ctrl-o`) does the opposite.
164
+ fn stays_open(&self, alternate: bool) -> bool {
165
+ self.cfg.keep_open != alternate
166
+ }
167
+
168
+ /// Open the selected project in its own editor.
169
+ fn open_current(&mut self, alternate: bool) {
162
170
  let Some(p) = self.current() else { return };
163
171
  match self.effective_ide(p) {
164
172
  Some((ide, _)) => {
165
- self.done = Some(Some(Picked {
166
- project: p.clone(),
167
- ide: ide.to_string(),
168
- }));
173
+ let ide = ide.to_string();
174
+ self.open_selected(ide, self.stays_open(alternate));
169
175
  }
170
176
  None => self.status = Some(no_editor_message(p)),
171
177
  }
172
178
  }
173
179
 
180
+ /// Start `ide` on the selected project. On success the picker closes, or
181
+ /// stays open (ready for the next project) if `stay`. On failure it always
182
+ /// stays open and says why.
183
+ fn open_selected(&mut self, ide: String, stay: bool) {
184
+ let Some(&idx) = self.list.selected().and_then(|i| self.visible.get(i)) else {
185
+ return;
186
+ };
187
+ let project = self.projects[idx].clone();
188
+ match self.host.open(&project, &ide) {
189
+ Ok(()) => {
190
+ // Mirror what the host saved, so the list and details are current.
191
+ self.projects[idx].ide = Some(ide.clone());
192
+ self.projects[idx].last_opened = Some(now());
193
+ if stay {
194
+ self.stage = Stage::Projects;
195
+ self.status = Some(format!("opened {} in {ide}", project.name));
196
+ } else {
197
+ self.done = true;
198
+ }
199
+ }
200
+ Err(e) => self.status = Some(format!("{e:#}")),
201
+ }
202
+ }
203
+
174
204
  /// Show the "Open with..." list (Right arrow). Only editors that can open
175
205
  /// this kind of project (folder or workspace file) are listed.
176
206
  fn enter_editors(&mut self) {
@@ -185,12 +215,9 @@ impl<'a> App<'a> {
185
215
  self.stage = Stage::Editors;
186
216
  }
187
217
 
188
- fn open_with(&mut self, index: usize) {
189
- if let (Some(p), Some(ide)) = (self.current(), self.editors.get(index)) {
190
- self.done = Some(Some(Picked {
191
- project: p.clone(),
192
- ide: ide.clone(),
193
- }));
218
+ fn open_with(&mut self, index: usize, alternate: bool) {
219
+ if let Some(ide) = self.editors.get(index).cloned() {
220
+ self.open_selected(ide, self.stays_open(alternate));
194
221
  }
195
222
  }
196
223
 
@@ -202,8 +229,8 @@ impl<'a> App<'a> {
202
229
  };
203
230
  self.projects[idx].ide = ide.clone();
204
231
  let p = &self.projects[idx];
205
- (self.on_ide_change)(&p.path, ide.as_deref());
206
- let (name, remembered) = self.effective_ide(p).unwrap_or(("none", false));
232
+ self.host.ide_changed(&p.path, ide.as_deref());
233
+ let (name, remembered) = self.cfg.effective_ide(p).unwrap_or(("none", false));
207
234
  self.status = Some(format!(
208
235
  "{}: editor is now {name}{}",
209
236
  p.name,
@@ -235,23 +262,41 @@ impl<'a> App<'a> {
235
262
  }
236
263
 
237
264
  fn on_key(&mut self, code: KeyCode, mods: KeyModifiers) {
238
- let ctrl = mods.contains(KeyModifiers::CONTROL);
265
+ let mut ctrl = mods.contains(KeyModifiers::CONTROL);
266
+ // Some terminals (and Windows consoles) deliver Ctrl+letter as the raw
267
+ // control character (^O = 0x0F), with or without the Ctrl modifier. Treat
268
+ // it as Ctrl+letter instead of typing garbage into the search box. Tab,
269
+ // Enter and Backspace arrive as their own keys, so their code points
270
+ // are left alone.
271
+ let code = match code {
272
+ KeyCode::Char(c) if ('\x01'..='\x1a').contains(&c) && !"\x08\t\n\r".contains(c) => {
273
+ ctrl = true;
274
+ KeyCode::Char((c as u8 - 1 + b'a') as char)
275
+ }
276
+ // With a non-Latin keyboard layout active, Ctrl+<key> reports that
277
+ // layout's letter (Russian: Ctrl+O arrives as Ctrl+щ). Shortcuts
278
+ // should work in any layout, so map by key position.
279
+ KeyCode::Char(c) if ctrl => KeyCode::Char(latin_key(c)),
280
+ other => other,
281
+ };
239
282
  self.status = None;
240
283
  match (code, ctrl) {
241
- (KeyCode::Char('c'), true) => self.done = Some(None),
284
+ (KeyCode::Char('c'), true) => self.done = true,
242
285
  (KeyCode::Down, _) | (KeyCode::Char('n' | 'j'), true) => self.move_sel(1),
243
286
  (KeyCode::Up, _) | (KeyCode::Char('p' | 'k'), true) => self.move_sel(-1),
244
287
  _ => match self.stage {
245
288
  Stage::Projects => self.on_key_projects(code, ctrl),
246
- Stage::Editors => self.on_key_editors(code),
289
+ Stage::Editors => self.on_key_editors(code, ctrl),
247
290
  },
248
291
  }
249
292
  }
250
293
 
251
294
  fn on_key_projects(&mut self, code: KeyCode, ctrl: bool) {
252
295
  match (code, ctrl) {
253
- (KeyCode::Esc, _) => self.done = Some(None),
254
- (KeyCode::Enter, _) => self.open_current(),
296
+ (KeyCode::Esc, _) => self.done = true,
297
+ (KeyCode::Enter, _) => self.open_current(false),
298
+ // Ctrl-O: open, but do the opposite of Enter about closing the picker.
299
+ (KeyCode::Char('o'), true) => self.open_current(true),
255
300
  (KeyCode::Right, _) => self.enter_editors(),
256
301
  (KeyCode::Char('e'), true) => self.cycle_ide(),
257
302
  (KeyCode::Tab, _) => self.cycle_tag(true),
@@ -264,7 +309,7 @@ impl<'a> App<'a> {
264
309
  self.query.pop();
265
310
  self.refilter();
266
311
  }
267
- (KeyCode::Char(c), false) => {
312
+ (KeyCode::Char(c), false) if !c.is_control() => {
268
313
  self.query.push(c);
269
314
  self.refilter();
270
315
  }
@@ -272,18 +317,23 @@ impl<'a> App<'a> {
272
317
  }
273
318
  }
274
319
 
275
- fn on_key_editors(&mut self, code: KeyCode) {
276
- match code {
277
- KeyCode::Esc | KeyCode::Left | KeyCode::Backspace => self.stage = Stage::Projects,
278
- KeyCode::Enter => {
320
+ fn on_key_editors(&mut self, code: KeyCode, ctrl: bool) {
321
+ match (code, ctrl) {
322
+ (KeyCode::Esc | KeyCode::Left | KeyCode::Backspace, _) => self.stage = Stage::Projects,
323
+ (KeyCode::Enter, _) => {
324
+ if let Some(i) = self.editor_list.selected() {
325
+ self.open_with(i, false);
326
+ }
327
+ }
328
+ (KeyCode::Char('o'), true) => {
279
329
  if let Some(i) = self.editor_list.selected() {
280
- self.open_with(i);
330
+ self.open_with(i, true);
281
331
  }
282
332
  }
283
- KeyCode::Char(c @ '1'..='9') => {
333
+ (KeyCode::Char(c @ '1'..='9'), false) => {
284
334
  let i = c as usize - '1' as usize;
285
335
  if i < self.editors.len() {
286
- self.open_with(i);
336
+ self.open_with(i, false);
287
337
  }
288
338
  }
289
339
  _ => {}
@@ -530,14 +580,33 @@ impl<'a> App<'a> {
530
580
  );
531
581
  return;
532
582
  }
583
+ let (enter, ctrl_o) = if self.cfg.keep_open {
584
+ ("open, stay", "open, quit")
585
+ } else {
586
+ ("open", "open, stay")
587
+ };
533
588
  let text = match self.stage {
534
- Stage::Projects => " ⏎ open → open with… ^e switch editor ⇥ tag esc quit",
535
- Stage::Editors => " ⏎ open 1-9 quick open esc back",
589
+ Stage::Projects => format!(
590
+ " ⏎ {enter} ^o {ctrl_o} → open with… ^e switch editor ⇥ tag esc quit"
591
+ ),
592
+ Stage::Editors => format!(" ⏎ {enter} 1-9 quick open ^o {ctrl_o} esc back"),
536
593
  };
537
594
  f.render_widget(Paragraph::new(text).style(Style::new().fg(DIM)), area);
538
595
  }
539
596
  }
540
597
 
598
+ /// The Latin letter on the same physical key as a Russian (ЙЦУКЕН) letter, so
599
+ /// `Ctrl+щ` works as `Ctrl+o`. Anything else is returned unchanged.
600
+ fn latin_key(c: char) -> char {
601
+ const RU: &str = "йцукенгшщзфывапролдячсмить";
602
+ const EN: &str = "qwertyuiopasdfghjklzxcvbnm";
603
+ let lower = c.to_lowercase().next().unwrap_or(c);
604
+ RU.chars()
605
+ .position(|r| r == lower)
606
+ .and_then(|i| EN.chars().nth(i))
607
+ .unwrap_or(c)
608
+ }
609
+
541
610
  fn no_editor_message(p: &Project) -> String {
542
611
  format!(
543
612
  "no configured editor can open this {}; add {} to `ides`",
@@ -577,43 +646,87 @@ fn ago(ts: Option<i64>) -> String {
577
646
  }
578
647
  }
579
648
 
580
- /// Run the picker. Returns `None` if the user cancelled.
649
+ /// Run the picker until the user quits. Opening and saving go through `host`.
581
650
  pub fn pick<'a>(
582
651
  projects: Vec<Project>,
583
652
  tags: Vec<String>,
584
653
  cfg: &'a Config,
585
- on_ide_change: IdeChanged<'a>,
586
- ) -> Result<Option<Picked>> {
654
+ host: Box<dyn Host + 'a>,
655
+ ) -> Result<()> {
587
656
  let mut terminal: DefaultTerminal = ratatui::init();
588
- let result = run_loop(&mut terminal, App::new(projects, tags, cfg, on_ide_change));
657
+ let result = run_loop(&mut terminal, App::new(projects, tags, cfg, host));
589
658
  ratatui::restore();
590
659
  result
591
660
  }
592
661
 
593
- fn run_loop(terminal: &mut DefaultTerminal, mut app: App) -> Result<Option<Picked>> {
594
- loop {
662
+ fn run_loop(terminal: &mut DefaultTerminal, mut app: App) -> Result<()> {
663
+ while !app.done {
595
664
  terminal.draw(|f| app.draw(f))?;
596
665
  if let Event::Key(key) = event::read()?
597
666
  && key.kind == KeyEventKind::Press
598
667
  {
599
668
  app.on_key(key.code, key.modifiers);
600
669
  }
601
- if let Some(done) = app.done.take() {
602
- return Ok(done);
603
- }
604
670
  }
671
+ Ok(())
605
672
  }
606
673
 
607
674
  #[cfg(test)]
608
675
  mod tests {
609
676
  use super::*;
677
+ use anyhow::bail;
610
678
  use ratatui::Terminal;
611
679
  use ratatui::backend::TestBackend;
612
- use std::cell::RefCell;
680
+ use std::cell::{Cell, RefCell};
613
681
  use std::path::PathBuf;
614
682
  use std::rc::Rc;
615
683
 
616
- type Saved = Rc<RefCell<Vec<(PathBuf, Option<String>)>>>;
684
+ type IdeLog = Rc<RefCell<Vec<(PathBuf, Option<String>)>>>;
685
+
686
+ /// What the fake host saw, shared with the test.
687
+ #[derive(Clone, Default)]
688
+ struct Rec {
689
+ ide_changes: IdeLog,
690
+ /// (project name, editor) for every successful open.
691
+ opened: Rc<RefCell<Vec<(String, String)>>>,
692
+ fail_open: Rc<Cell<bool>>,
693
+ }
694
+
695
+ impl Rec {
696
+ fn ide_log(&self) -> Vec<Option<String>> {
697
+ self.ide_changes
698
+ .borrow()
699
+ .iter()
700
+ .map(|(_, i)| i.clone())
701
+ .collect()
702
+ }
703
+
704
+ fn opened(&self) -> Vec<(String, String)> {
705
+ self.opened.borrow().clone()
706
+ }
707
+ }
708
+
709
+ struct FakeHost(Rec);
710
+
711
+ impl Host for FakeHost {
712
+ fn ide_changed(&mut self, path: &Path, ide: Option<&str>) {
713
+ self.0
714
+ .ide_changes
715
+ .borrow_mut()
716
+ .push((path.to_path_buf(), ide.map(String::from)));
717
+ }
718
+
719
+ fn open(&mut self, p: &Project, ide: &str) -> Result<()> {
720
+ if self.0.fail_open.get() {
721
+ bail!("command not found: {ide}");
722
+ }
723
+ self.0
724
+ .opened
725
+ .borrow_mut()
726
+ .push((p.name.clone(), ide.to_string()));
727
+ Ok(())
728
+ }
729
+ }
617
730
 
618
731
  fn proj(name: &str, tags: &[&str], ide: Option<&str>) -> Project {
619
732
  Project {
@@ -632,8 +745,26 @@ mod tests {
632
745
  }
633
746
  }
634
747
 
748
+ /// Three projects (beta already remembers vim) with a recording host.
749
+ fn app(cfg: &Config) -> (App<'_>, Rec) {
750
+ let rec = Rec::default();
751
+ let ps = vec![
752
+ proj("alpha", &["work"], None),
753
+ proj("beta", &["oss"], Some("vim")),
754
+ proj("gamma", &["work"], None),
755
+ ];
756
+ let app = App::new(
757
+ ps,
758
+ vec!["oss".into(), "work".into()],
759
+ cfg,
760
+ Box::new(FakeHost(rec.clone())),
761
+ );
762
+ (app, rec)
763
+ }
764
+
635
765
  /// A folder and a VS Code workspace file side by side.
636
- fn app_with_workspace(cfg: &Config) -> App<'_> {
766
+ fn app_with_workspace(cfg: &Config) -> (App<'_>, Rec) {
767
+ let rec = Rec::default();
637
768
  let ps = vec![
638
769
  proj("alpha", &[], None),
639
770
  Project {
@@ -644,29 +775,8 @@ mod tests {
644
775
  ide: None,
645
776
  },
646
777
  ];
647
- App::new(ps, vec![], cfg, Box::new(|_, _| {}))
648
- }
649
-
650
- /// Three projects; every editor change is recorded in the returned log,
651
- /// standing in for the saved store.
652
- fn app(cfg: &Config) -> (App<'_>, Saved) {
653
- let saved: Saved = Default::default();
654
- let sink = saved.clone();
655
- let ps = vec![
656
- proj("alpha", &["work"], None),
657
- proj("beta", &["oss"], Some("vim")),
658
- proj("gamma", &["work"], None),
659
- ];
660
- let app = App::new(
661
- ps,
662
- vec!["oss".into(), "work".into()],
663
- cfg,
664
- Box::new(move |p, ide| {
665
- sink.borrow_mut()
666
- .push((p.to_path_buf(), ide.map(String::from)))
667
- }),
668
- );
669
- (app, saved)
778
+ let app = App::new(ps, vec![], cfg, Box::new(FakeHost(rec.clone())));
779
+ (app, rec)
670
780
  }
671
781
 
672
782
  fn key(a: &mut App, code: KeyCode) {
@@ -683,6 +793,21 @@ mod tests {
683
793
  }
684
794
  }
685
795
 
796
+ /// Everything on screen as one string.
797
+ fn screen(a: &mut App) -> String {
798
+ let mut t = Terminal::new(TestBackend::new(100, 24)).unwrap();
799
+ t.draw(|f| a.draw(f)).unwrap();
800
+ let buf = t.backend().buffer().clone();
801
+ let mut out = String::new();
802
+ for y in 0..buf.area.height {
803
+ for x in 0..buf.area.width {
804
+ out.push_str(buf[(x, y)].symbol());
805
+ }
806
+ out.push('\n');
807
+ }
808
+ out
809
+ }
810
+
686
811
  #[test]
687
812
  fn fuzzy_filter_narrows_and_ranks() {
688
813
  let cfg = three_ides();
@@ -707,52 +832,188 @@ mod tests {
707
832
  }
708
833
 
709
834
  #[test]
710
- fn enter_opens_immediately_in_the_global_default() {
835
+ fn enter_opens_in_the_global_default_and_closes() {
711
836
  let cfg = three_ides();
712
- let (mut a, _) = app(&cfg);
837
+ let (mut a, rec) = app(&cfg);
713
838
  type_str(&mut a, "alpha");
714
839
  key(&mut a, KeyCode::Enter);
715
- let picked = a.done.take().unwrap().unwrap();
716
- assert_eq!(picked.project.name, "alpha");
717
- assert_eq!(picked.ide, "code");
840
+ assert!(a.done);
841
+ assert_eq!(rec.opened(), vec![("alpha".into(), "code".into())]);
718
842
  }
719
843
 
720
844
  #[test]
721
845
  fn enter_uses_the_projects_own_editor() {
722
846
  let cfg = three_ides();
723
- let (mut a, _) = app(&cfg);
847
+ let (mut a, rec) = app(&cfg);
724
848
  type_str(&mut a, "beta"); // remembered vim
725
849
  key(&mut a, KeyCode::Enter);
726
- assert_eq!(a.done.take().unwrap().unwrap().ide, "vim");
850
+ assert_eq!(rec.opened(), vec![("beta".into(), "vim".into())]);
727
851
  }
728
852
 
729
853
  #[test]
730
854
  fn right_arrow_lists_editors_remembered_first_and_number_opens() {
731
855
  let cfg = three_ides();
732
- let (mut a, _) = app(&cfg);
856
+ let (mut a, rec) = app(&cfg);
733
857
  type_str(&mut a, "beta");
734
858
  key(&mut a, KeyCode::Right);
735
859
  assert!(a.stage == Stage::Editors);
736
860
  assert_eq!(a.editors, vec!["vim", "code", "pycharm"]);
737
861
  key(&mut a, KeyCode::Char('3'));
738
- assert_eq!(a.done.take().unwrap().unwrap().ide, "pycharm");
862
+ assert_eq!(rec.opened(), vec![("beta".into(), "pycharm".into())]);
863
+ assert!(a.done);
739
864
  }
740
865
 
741
866
  #[test]
742
867
  fn esc_goes_back_then_quits() {
743
868
  let cfg = three_ides();
744
- let (mut a, _) = app(&cfg);
869
+ let (mut a, rec) = app(&cfg);
745
870
  key(&mut a, KeyCode::Right);
746
871
  key(&mut a, KeyCode::Esc);
747
- assert!(a.stage == Stage::Projects && a.done.is_none());
872
+ assert!(a.stage == Stage::Projects && !a.done);
748
873
  key(&mut a, KeyCode::Esc);
749
- assert!(matches!(a.done, Some(None)));
874
+ assert!(a.done);
875
+ assert!(rec.opened().is_empty());
876
+ }
877
+
878
+ #[test]
879
+ fn ctrl_o_opens_and_keeps_the_picker_open_for_the_next_project() {
880
+ let cfg = three_ides();
881
+ let (mut a, rec) = app(&cfg);
882
+ type_str(&mut a, "alpha");
883
+ ctrl(&mut a, 'o');
884
+ assert!(!a.done, "picker must stay open");
885
+ assert_eq!(a.status.as_deref(), Some("opened alpha in code"));
886
+
887
+ // Straight on to a second project, in the same session.
888
+ ctrl(&mut a, 'u');
889
+ type_str(&mut a, "gamma");
890
+ ctrl(&mut a, 'o');
891
+ assert!(!a.done);
892
+ assert_eq!(
893
+ rec.opened(),
894
+ vec![
895
+ ("alpha".into(), "code".into()),
896
+ ("gamma".into(), "code".into())
897
+ ]
898
+ );
899
+
900
+ // The list reflects what was just done.
901
+ let alpha = a.projects.iter().find(|p| p.name == "alpha").unwrap();
902
+ assert_eq!(alpha.ide.as_deref(), Some("code"));
903
+ assert!(alpha.last_opened.is_some());
904
+
905
+ key(&mut a, KeyCode::Esc);
906
+ assert!(a.done);
907
+ }
908
+
909
+ #[test]
910
+ fn ctrl_o_in_the_editor_list_opens_with_that_editor_and_stays() {
911
+ let cfg = three_ides();
912
+ let (mut a, rec) = app(&cfg);
913
+ type_str(&mut a, "beta");
914
+ key(&mut a, KeyCode::Right);
915
+ key(&mut a, KeyCode::Down); // "code"
916
+ ctrl(&mut a, 'o');
917
+ assert_eq!(rec.opened(), vec![("beta".into(), "code".into())]);
918
+ assert!(!a.done);
919
+ assert!(a.stage == Stage::Projects, "back to the project list");
920
+ }
921
+
922
+ #[test]
923
+ fn keep_open_setting_swaps_enter_and_ctrl_o() {
924
+ let cfg = Config {
925
+ keep_open: true,
926
+ ..three_ides()
927
+ };
928
+ let (mut a, rec) = app(&cfg);
929
+ type_str(&mut a, "alpha");
930
+ key(&mut a, KeyCode::Enter);
931
+ assert!(!a.done, "Enter stays open with keep_open");
932
+ ctrl(&mut a, 'o');
933
+ assert!(a.done, "Ctrl-o quits with keep_open");
934
+ assert_eq!(rec.opened().len(), 2);
935
+ }
936
+
937
+ #[test]
938
+ fn a_failed_open_keeps_the_picker_open_and_says_why() {
939
+ let cfg = three_ides();
940
+ let (mut a, rec) = app(&cfg);
941
+ rec.fail_open.set(true);
942
+ type_str(&mut a, "alpha");
943
+ key(&mut a, KeyCode::Enter);
944
+ assert!(!a.done);
945
+ assert_eq!(a.status.as_deref(), Some("command not found: code"));
946
+ assert!(rec.opened().is_empty());
947
+ // Nothing is remembered for a launch that did not happen.
948
+ assert_eq!(a.current().unwrap().ide, None);
949
+ assert_eq!(a.current().unwrap().last_opened, None);
950
+ }
951
+
952
+ #[test]
953
+ fn raw_control_characters_act_as_ctrl_keys_and_never_enter_the_search() {
954
+ let cfg = three_ides();
955
+ let (mut a, rec) = app(&cfg);
956
+ type_str(&mut a, "alpha");
957
+ // ^O as a bare 0x0F, the way some terminals deliver it.
958
+ a.on_key(KeyCode::Char('\x0f'), KeyModifiers::NONE);
959
+ assert!(!a.done);
960
+ assert_eq!(rec.opened(), vec![("alpha".into(), "code".into())]);
961
+ // ^U (0x15) clears the search; nothing invisible is left behind.
962
+ a.on_key(KeyCode::Char('\x15'), KeyModifiers::NONE);
963
+ assert_eq!(a.query, "");
964
+ type_str(&mut a, "gamma");
965
+ assert_eq!(a.query, "gamma");
966
+ assert_eq!(a.current().unwrap().name, "gamma");
967
+ // A control character that means nothing is simply ignored.
968
+ a.on_key(KeyCode::Char('\x07'), KeyModifiers::NONE);
969
+ assert_eq!(a.query, "gamma");
970
+ }
971
+
972
+ #[test]
973
+ fn shortcuts_work_with_a_russian_keyboard_layout() {
974
+ let cfg = three_ides();
975
+ let (mut a, rec) = app(&cfg);
976
+ type_str(&mut a, "alpha");
977
+ // Ctrl+O with the Russian layout active arrives as Ctrl+щ.
978
+ a.on_key(KeyCode::Char('щ'), KeyModifiers::CONTROL);
979
+ assert_eq!(rec.opened(), vec![("alpha".into(), "code".into())]);
980
+ assert!(!a.done);
981
+ // Ctrl+у is Ctrl+e: switch editor.
982
+ a.on_key(KeyCode::Char('у'), KeyModifiers::CONTROL);
983
+ assert_eq!(a.current().unwrap().ide.as_deref(), Some("pycharm"));
984
+ // Ctrl+г is Ctrl+u: clear the search.
985
+ a.on_key(KeyCode::Char('г'), KeyModifiers::CONTROL);
986
+ assert_eq!(a.query, "");
987
+ // Plain Cyrillic typing still searches (project names may be Russian).
988
+ type_str(&mut a, "щ");
989
+ assert_eq!(a.query, "щ");
990
+ assert_eq!(latin_key('Щ'), 'o');
991
+ assert_eq!(latin_key('x'), 'x');
992
+ }
993
+
994
+ #[test]
995
+ fn the_hint_line_describes_the_keys_for_the_current_setting() {
996
+ let cfg = three_ides();
997
+ let (mut a, _) = app(&cfg);
998
+ let s = screen(&mut a);
999
+ assert!(s.contains("⏎ open ") && s.contains("^o open, stay"), "{s}");
1000
+
1001
+ let cfg = Config {
1002
+ keep_open: true,
1003
+ ..three_ides()
1004
+ };
1005
+ let (mut a, _) = app(&cfg);
1006
+ let s = screen(&mut a);
1007
+ assert!(
1008
+ s.contains("⏎ open, stay") && s.contains("^o open, quit"),
1009
+ "{s}"
1010
+ );
750
1011
  }
751
1012
 
752
1013
  #[test]
753
1014
  fn ctrl_e_cycles_editors_saves_each_and_wraps_to_default() {
754
1015
  let cfg = three_ides();
755
- let (mut a, saved) = app(&cfg);
1016
+ let (mut a, rec) = app(&cfg);
756
1017
  type_str(&mut a, "alpha");
757
1018
 
758
1019
  ctrl(&mut a, 'e');
@@ -762,27 +1023,29 @@ mod tests {
762
1023
  ctrl(&mut a, 'e'); // back to the global default, stored as "not set"
763
1024
  assert_eq!(a.current().unwrap().ide, None);
764
1025
 
765
- let log: Vec<_> = saved.borrow().iter().map(|(_, i)| i.clone()).collect();
766
- assert_eq!(log, vec![Some("pycharm".into()), Some("vim".into()), None]);
1026
+ assert_eq!(
1027
+ rec.ide_log(),
1028
+ vec![Some("pycharm".into()), Some("vim".into()), None]
1029
+ );
767
1030
  }
768
1031
 
769
1032
  #[test]
770
1033
  fn enter_after_ctrl_e_opens_the_chosen_editor() {
771
1034
  let cfg = three_ides();
772
- let (mut a, _) = app(&cfg);
1035
+ let (mut a, rec) = app(&cfg);
773
1036
  type_str(&mut a, "alpha");
774
1037
  ctrl(&mut a, 'e');
775
1038
  key(&mut a, KeyCode::Enter);
776
- assert_eq!(a.done.take().unwrap().unwrap().ide, "pycharm");
1039
+ assert_eq!(rec.opened(), vec![("alpha".into(), "pycharm".into())]);
777
1040
  }
778
1041
 
779
1042
  #[test]
780
1043
  fn ctrl_e_with_one_editor_explains_instead_of_changing() {
781
1044
  let cfg = Config::default();
782
- let (mut a, saved) = app(&cfg);
1045
+ let (mut a, rec) = app(&cfg);
783
1046
  ctrl(&mut a, 'e');
784
1047
  assert!(a.status.as_deref().unwrap().contains("only one editor"));
785
- assert!(saved.borrow().is_empty());
1048
+ assert!(rec.ide_log().is_empty());
786
1049
  }
787
1050
 
788
1051
  #[test]
@@ -791,17 +1054,17 @@ mod tests {
791
1054
  ides: vec!["pycharm".into(), "code".into(), "vim".into()],
792
1055
  ..Config::default()
793
1056
  };
794
- let mut a = app_with_workspace(&cfg);
1057
+ let (mut a, rec) = app_with_workspace(&cfg);
795
1058
  type_str(&mut a, "shop");
796
1059
  // Enter picks the first editor that can open it: "code", not "pycharm".
797
1060
  key(&mut a, KeyCode::Right);
798
1061
  assert_eq!(a.editors, vec!["code"]);
799
1062
  key(&mut a, KeyCode::Esc);
800
1063
  key(&mut a, KeyCode::Enter);
801
- assert_eq!(a.done.take().unwrap().unwrap().ide, "code");
1064
+ assert_eq!(rec.opened(), vec![("shop".into(), "code".into())]);
802
1065
 
803
1066
  // The folder next to it still gets everything.
804
- let mut a = app_with_workspace(&cfg);
1067
+ let (mut a, _) = app_with_workspace(&cfg);
805
1068
  type_str(&mut a, "alpha");
806
1069
  key(&mut a, KeyCode::Right);
807
1070
  assert_eq!(a.editors, vec!["pycharm", "code", "vim"]);
@@ -813,10 +1076,10 @@ mod tests {
813
1076
  ides: vec!["pycharm".into(), "vim".into()],
814
1077
  ..Config::default()
815
1078
  };
816
- let mut a = app_with_workspace(&cfg);
1079
+ let (mut a, rec) = app_with_workspace(&cfg);
817
1080
  type_str(&mut a, "shop");
818
1081
  key(&mut a, KeyCode::Enter);
819
- assert!(a.done.is_none());
1082
+ assert!(!a.done && rec.opened().is_empty());
820
1083
  let msg = a.status.clone().unwrap();
821
1084
  assert!(
822
1085
  msg.contains("VS Code workspace") && msg.contains("vscode"),
@@ -832,7 +1095,7 @@ mod tests {
832
1095
  ides: vec!["code".into(), "pycharm".into(), "codium".into()],
833
1096
  ..Config::default()
834
1097
  };
835
- let mut a = app_with_workspace(&cfg);
1098
+ let (mut a, _) = app_with_workspace(&cfg);
836
1099
  type_str(&mut a, "shop");
837
1100
  ctrl(&mut a, 'e');
838
1101
  assert_eq!(a.current().unwrap().ide.as_deref(), Some("codium")); // skips pycharm
@@ -843,17 +1106,11 @@ mod tests {
843
1106
  #[test]
844
1107
  fn workspaces_are_marked_in_the_list_and_described_in_details() {
845
1108
  let cfg = three_ides();
846
- let mut a = app_with_workspace(&cfg);
847
- let mut t = Terminal::new(TestBackend::new(96, 24)).unwrap();
1109
+ let (mut a, _) = app_with_workspace(&cfg);
848
1110
  type_str(&mut a, "shop");
849
- t.draw(|f| a.draw(f)).unwrap();
850
- let buf = t.backend().buffer().clone();
851
- let screen: String = (0..buf.area.height)
852
- .flat_map(|y| (0..buf.area.width).map(move |x| (x, y)).collect::<Vec<_>>())
853
- .map(|(x, y)| buf[(x, y)].symbol().to_string())
854
- .collect();
855
- assert!(screen.contains('◇'), "list marker missing");
856
- assert!(screen.contains("VS Code workspace"), "type row missing");
1111
+ let s = screen(&mut a);
1112
+ assert!(s.contains('◇'), "list marker missing");
1113
+ assert!(s.contains("VS Code workspace"), "type row missing");
857
1114
  }
858
1115
 
859
1116
  #[test]
@@ -874,16 +1131,10 @@ mod tests {
874
1131
  fn dump_screens() {
875
1132
  let cfg = three_ides();
876
1133
  let (mut a, _) = app(&cfg);
877
- let mut t = Terminal::new(TestBackend::new(96, 24)).unwrap();
878
1134
  for step in 0..2 {
879
- t.draw(|f| a.draw(f)).unwrap();
880
- let buf = t.backend().buffer().clone();
881
1135
  println!("--- screen {step} ---");
882
- for y in 0..buf.area.height {
883
- let row: String = (0..buf.area.width)
884
- .map(|x| buf[(x, y)].symbol().to_string())
885
- .collect();
886
- println!("{}", row.trim_end());
1136
+ for line in screen(&mut a).lines() {
1137
+ println!("{}", line.trim_end());
887
1138
  }
888
1139
  key(&mut a, KeyCode::Right);
889
1140
  }
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes