1use std::{collections::HashMap, io, time::Duration};
8
9use omp_tui::{
10 App, AppEvent, AppOptions, Cached, Elements, Graphics, Prop, Props, Size, TerminalCaps, Ui,
11 UiContext, components::Img, dom,
12};
13
14const SCROLL_ID: &str = "login-providers";
15const HUD_ID: &str = "paint-hud";
16const CHROME_ROWS: u16 = 5;
17const ASSET_DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/examples/assets/login");
18const CARD_W: u16 = 16;
25
26struct Provider {
27 id: &'static str,
28 name: &'static str,
30}
31
32const PROVIDERS: &[Provider] = &[
35 Provider { id: "openai-codex", name: "Codex" },
36 Provider { id: "anthropic", name: "Claude" },
37 Provider { id: "zai", name: "Z.AI" },
38 Provider { id: "zai-coding-plan", name: "Z.AI GLM" },
39 Provider { id: "kimi-code", name: "Kimi Code" },
40 Provider { id: "openrouter", name: "OpenRouter" },
41 Provider { id: "github-copilot", name: "Copilot" },
42 Provider { id: "cursor", name: "Cursor" },
43 Provider { id: "devin", name: "Devin" },
44 Provider { id: "google-antigravity", name: "Antigravity" },
45 Provider { id: "google-gemini-cli", name: "Gemini CLI" },
46 Provider { id: "openai-codex-device", name: "Codex CLI" },
47 Provider { id: "xai", name: "xAI" },
48 Provider { id: "xai-oauth", name: "Grok" },
49 Provider { id: "gitlab-duo", name: "GitLab Duo" },
50 Provider { id: "gitlab-duo-agent", name: "Duo Agent" },
51 Provider { id: "alibaba-coding-plan", name: "Alibaba" },
52 Provider { id: "alibaba-token-plan", name: "QwenCloud" },
53 Provider { id: "aiand", name: "ai&" },
54 Provider { id: "zhipu-coding-plan", name: "Zhipu" },
55 Provider { id: "umans", name: "Umans" },
56 Provider { id: "qwen-portal", name: "Qwen" },
57 Provider { id: "sakana", name: "Sakana" },
58 Provider { id: "minimax-code", name: "MiniMax" },
59 Provider { id: "minimax-code-cn", name: "MiniMax CN" },
60 Provider { id: "xiaomi", name: "MiMo" },
61 Provider { id: "xiaomi-token-plan-sgp", name: "MiMo SGP" },
62 Provider { id: "xiaomi-token-plan-ams", name: "MiMo EU" },
63 Provider { id: "xiaomi-token-plan-cn", name: "MiMo CN" },
64 Provider { id: "firepass", name: "Fire Pass" },
65 Provider { id: "deepseek", name: "DeepSeek" },
66 Provider { id: "meta", name: "Meta" },
67 Provider { id: "moonshot", name: "Moonshot" },
68 Provider { id: "cerebras", name: "Cerebras" },
69 Provider { id: "baseten", name: "Baseten" },
70 Provider { id: "fireworks", name: "Fireworks" },
71 Provider { id: "together", name: "Together" },
72 Provider { id: "nvidia", name: "NVIDIA" },
73 Provider { id: "novita", name: "Novita" },
74 Provider { id: "huggingface", name: "HuggingFace" },
75 Provider { id: "perplexity", name: "Perplexity" },
76 Provider { id: "qianfan", name: "Qianfan" },
77 Provider { id: "venice", name: "Venice" },
78 Provider { id: "siliconflow", name: "SiliconFlow" },
79 Provider { id: "siliconflow-cn", name: "SF China" },
80 Provider { id: "synthetic", name: "Synthetic" },
81 Provider { id: "nanogpt", name: "NanoGPT" },
82 Provider { id: "wafer-serverless", name: "Wafer" },
83 Provider { id: "coreweave", name: "CoreWeave" },
84 Provider { id: "vercel-ai-gateway", name: "Vercel AI" },
85 Provider { id: "cloudflare-ai-gateway", name: "Cloudflare" },
86 Provider { id: "litellm", name: "LiteLLM" },
87 Provider { id: "kilo", name: "Kilo" },
88 Provider { id: "zenmux", name: "ZenMux" },
89 Provider { id: "opencode-zen", name: "OpenCode Zen" },
90 Provider { id: "opencode-go", name: "OpenCode Go" },
91 Provider { id: "tavily", name: "Tavily" },
92 Provider { id: "kagi", name: "Kagi" },
93 Provider { id: "exa", name: "Exa" },
94 Provider { id: "parallel", name: "Parallel" },
95 Provider { id: "ollama", name: "Ollama" },
96 Provider { id: "ollama-cloud", name: "Ollama Cloud" },
97 Provider { id: "lm-studio", name: "LM Studio" },
98 Provider { id: "llama.cpp", name: "llama.cpp" },
99 Provider { id: "vllm", name: "vLLM" },
100 Provider { id: "gmi-cloud", name: "GMI Cloud" },
101];
102
103fn scroll_height(viewport: Size) -> u16 {
104 viewport.height.saturating_sub(CHROME_ROWS).max(3)
105}
106
107fn build_ui(viewport: Size, context: UiContext) -> Ui {
108 let ids = PROVIDERS
109 .iter()
110 .enumerate()
111 .map(|(index, provider)| {
112 (format!("{ASSET_DIR}/{}.png", provider.id), u32::try_from(index + 1).unwrap())
113 })
114 .collect::<HashMap<_, _>>();
115 let elements = Elements::builder()
116 .with("logo", move |_: &str, props: Props, _: Vec<Cached>| {
117 let source = props.str_of(Prop::Src).map_or("", |value| value.as_str());
118 let id = ids.get(source).copied().unwrap_or(1);
119 Box::new(
120 Img::new()
121 .with_str(Prop::Src, source)
122 .with(Prop::W, 4_u16)
123 .kitty(id, 2, 4),
124 ) as Box<dyn omp_tui::Component>
125 })
126 .build();
127 let root = dom! {
128 <col gap=1>
129 <row gap=1>
130 <i:log-in/>
131 <text bold fg="accent..info">{"Choose a provider"}</text>
132 <text dim>{format!("{} providers", PROVIDERS.len())}</text>
133 </row>
134 <scroll id={SCROLL_ID} h={scroll_height(viewport)}>
135 <row wrap gap=1 justify=center>
136 for provider in PROVIDERS.iter() {
137 <box focus id={provider.id} w={CARD_W} border=round bc="muted..muted"
138 hover="#38bdf8..#c084fc" lift=1 anim=220 ease=in-out
139 align=center pad-x=1>
140 <logo src={format!("{ASSET_DIR}/{}.png", provider.id)}/>
141 <text bold truncate align=center>{provider.name}</text>
142 </box>
143 }
144 </row>
145 </scroll>
146 <row gap=2>
147 <text dim>{"↹/←→/↑↓ pick · ↵ login · wheel scroll · Ctrl-C quit"}</text>
148 <text id={HUD_ID} dim>{"repaint: 0 cells"}</text>
149 </row>
150 </col>
151 };
152 let context = UiContext { elements, ..context };
153 Ui::from_root(root, viewport.width, context)
154}
155
156fn show_stats(app: &mut App, chosen: Option<&str>) {
157 let caps = app.caps();
158 let stats = app.last_stats();
159 let pixels = caps.cell_px.map_or_else(
160 || "cell-px ?".to_owned(),
161 |(width, height)| format!("cell-px {width}×{height}"),
162 );
163 let login = chosen.map_or(String::new(), |id| format!("login: {id} · "));
164 app.ui_mut().set_text(
165 HUD_ID,
166 format!(
167 "{login}{} · {} · {} · repaint: {} cells",
168 graphics_label(caps.graphics),
169 caps.id,
170 pixels,
171 stats.changed_cells,
172 ),
173 );
174}
175
176fn forced_from_args(caps: &TerminalCaps) -> Option<Graphics> {
177 let mut forced = None;
178 for argument in std::env::args().skip(1) {
179 forced = match argument.as_str() {
180 "--cells" => Some(Graphics::Cells),
181 "--kitty" if caps.kitty_placeholders => Some(Graphics::KittyPlaceholders),
182 "--kitty" => Some(Graphics::KittyDirect),
183 "--kitty-placeholders" => Some(Graphics::KittyPlaceholders),
184 "--sixel" => Some(Graphics::Sixel),
185 _ => forced,
186 };
187 }
188 forced
189}
190
191#[tokio::main]
192async fn main() -> io::Result<()> {
193 let mut app = AppOptions::new()
194 .mouse()
195 .probe(Duration::from_millis(150))
196 .graphics_with(forced_from_args)
197 .start(|env| build_ui(env.viewport, env.ctx))
198 .await?;
199 if app.caps().graphics != Graphics::Cells {
200 for (index, provider) in PROVIDERS.iter().enumerate() {
201 let png = tokio::fs::read(format!("{ASSET_DIR}/{}.png", provider.id)).await?;
202 app.renderer_mut().register_image(
203 u32::try_from(index + 1).expect("provider count fits image IDs"),
204 png,
205 )?;
206 }
207 app.ui_mut().invalidate(SCROLL_ID);
208 }
209 show_stats(&mut app, None);
210 let mut chosen: Option<String> = None;
211 while let Some(event) = app.next().await? {
212 match event {
213 AppEvent::Resized(viewport) => {
214 app.ui_mut().set_height(SCROLL_ID, scroll_height(viewport));
215 },
216 AppEvent::Pressed(id) => chosen = Some(id.to_string()),
217 _ => {},
218 }
219 show_stats(&mut app, chosen.as_deref());
220 }
221 Ok(())
222}
223
224const fn graphics_label(graphics: Graphics) -> &'static str {
225 match graphics {
226 Graphics::Cells => "cells",
227 Graphics::Sixel => "sixel",
228 Graphics::KittyDirect => "kitty-direct",
229 Graphics::KittyPlaceholders => "kitty-placeholders",
230 Graphics::Iterm2 => "iterm2",
231 }
232}
233
234#[cfg(test)]
235mod tests {
236 use omp_tui::{Key, Mouse, Renderer, Size, test_support::TerminalModel};
237
238 use super::{CARD_W, PROVIDERS, build_ui, scroll_height};
239
240 fn replay(renderer: &mut Renderer<Vec<u8>>, terminal: &mut TerminalModel) {
241 let output =
242 String::from_utf8(std::mem::take(renderer.writer_mut())).expect("renderer emits UTF-8");
243 terminal.apply(&output);
244 }
245
246 fn first_card_line(ui: &omp_tui::Ui, width: u16) -> String {
249 let mut renderer = Renderer::new(Vec::new());
250 let mut terminal = TerminalModel::new(width.into(), 18);
251 renderer
252 .rebuild(ui.frame().clone(), 18, 0, "")
253 .expect("paint succeeds");
254 replay(&mut renderer, &mut terminal);
255 terminal
256 .visible_rows()
257 .iter()
258 .find(|row| row.contains('╭'))
259 .cloned()
260 .expect("a card border row is visible")
261 }
262
263 #[test]
264 fn grid_reflows_columns_and_recenters_on_resize() {
265 let mut context = omp_tui::UiContext::default();
266 context.graphics = omp_tui::Graphics::Cells;
267 let mut ui = build_ui(Size::new(100, 18), context);
268
269 let columns = |width: u16| usize::from(width / (CARD_W + 1));
272 let centered = |row: &str, inner: usize| {
273 let cells: Vec<char> = row.chars().collect();
274 let left = cells.iter().position(|&ch| ch == '╭').unwrap();
275 let last = cells.iter().rposition(|&ch| ch == '╮').unwrap();
276 let right = inner - 1 - last;
277 assert!(
278 left.abs_diff(right) <= 1,
279 "line not centered: left pad {left}, right pad {right}: {row}"
280 );
281 };
282
283 let narrow = first_card_line(&ui, 100);
284 assert_eq!(narrow.matches('╭').count(), columns(100));
285 centered(&narrow, 99);
286
287 ui.resize(150);
288 let wide = first_card_line(&ui, 150);
289 assert_eq!(wide.matches('╭').count(), columns(150));
290 assert!(wide.matches('╭').count() > narrow.matches('╭').count());
291 centered(&wide, 149);
292 }
293
294 #[test]
295 fn scrolling_keeps_damage_inside_viewport_and_decodes_vendored_logos() {
296 let viewport = Size::new(100, 18);
297 let mut context = omp_tui::UiContext::default();
298 context.graphics = omp_tui::Graphics::Cells;
299 let mut ui = build_ui(viewport, context);
300 let mut renderer = Renderer::new(Vec::new());
301 let mut terminal = TerminalModel::new(viewport.width.into(), viewport.height.into());
302 renderer
303 .rebuild(ui.frame().clone(), viewport.height, 0, "")
304 .expect("initial paint succeeds");
305 replay(&mut renderer, &mut terminal);
306
307 let initial = terminal.visible_rows().join("\n");
308 assert!(initial.contains(PROVIDERS[0].name));
309 assert!(initial.contains(PROVIDERS[1].name));
310 assert!(initial.contains('▀'), "decoded image must paint half-block cells:\n{initial}");
311 assert!(!initial.contains("[img:"), "vendored image unexpectedly failed to decode");
312
313 for (step, key) in [Key::Down, Key::PageDown, Key::Down, Key::PageDown]
314 .into_iter()
315 .enumerate()
316 {
317 ui.handle_key(key);
318 let stats = ui
319 .present(&mut renderer, viewport.height, 0)
320 .expect("scroll paint succeeds");
321 replay(&mut renderer, &mut terminal);
322 let scroll_area = usize::from(viewport.width) * usize::from(scroll_height(viewport));
323 assert!(
324 stats.changed_cells <= scroll_area,
325 "step {step} repainted {} cells outside {scroll_area}-cell scroll viewport",
326 stats.changed_cells,
327 );
328 }
329
330 ui.handle_mouse(1, 3, Mouse::WheelDown);
331 let stats = ui
332 .present(&mut renderer, viewport.height, 0)
333 .expect("wheel paint succeeds");
334 let scroll_area = usize::from(viewport.width) * usize::from(scroll_height(viewport));
335 assert!(stats.changed_cells <= scroll_area);
336 }
337
338 #[test]
339 fn kitty_mode_uploads_and_materializes_placeholders() {
340 let viewport = Size::new(100, 18);
341 let mut context = omp_tui::UiContext::default();
342 context.graphics = omp_tui::Graphics::KittyPlaceholders;
343 let ui = build_ui(viewport, context);
344 let mut renderer = Renderer::new(Vec::new());
345 for (index, provider) in PROVIDERS.iter().enumerate() {
346 let path = format!("{}/{}.png", super::ASSET_DIR, provider.id);
347 renderer
348 .register_image(
349 u32::try_from(index + 1).expect("provider count fits image IDs"),
350 std::fs::read(path).unwrap(),
351 )
352 .unwrap();
353 }
354 renderer
355 .rebuild(ui.frame().clone(), viewport.height, 0, "")
356 .expect("Kitty initial paint succeeds");
357 let output = String::from_utf8(renderer.into_inner()).unwrap();
358 assert!(output.starts_with("\x1b_Gf=100,t=d,a=t,i=1,q=2,"));
359 assert!(output.contains("\x1b_Ga=p,U=1,i=1,p=1028,r=2,c=4,q=2\x1b\\"));
361 assert!(output.contains("\u{10eeee}\u{0305}\u{0305}"));
362 assert!(output.contains("38;2;0;0;1m"));
365 assert!(output.contains("58:2::0:4:4"));
366 }
367
368 #[test]
369 fn hovering_a_card_lifts_it_and_recolors_its_ring() {
370 let mut context = omp_tui::UiContext::default();
371 context.graphics = omp_tui::Graphics::Cells;
372 let mut ui = build_ui(Size::new(100, 18), context);
373 let rows = |ui: &omp_tui::Ui| -> Vec<String> {
374 (0..ui.frame().size().height)
375 .map(|y| omp_tui::test_support::frame_row_text(ui.frame(), y))
376 .collect()
377 };
378 let resting = rows(&ui);
379 let rest_top = resting
380 .iter()
381 .position(|row| row.contains('╭'))
382 .expect("cards render a border row");
383 let column = resting[rest_top]
384 .chars()
385 .position(|glyph| glyph == '╭')
386 .unwrap() as u16;
387
388 ui.handle_mouse(column + 2, rest_top as u16 + 2, Mouse::Move);
390 ui.tick(std::time::Duration::from_millis(400));
391 let lifted = rows(&ui);
392 let lifted_top = lifted.iter().position(|row| row.contains('╭')).unwrap();
393 assert_eq!(lifted_top + 1, rest_top, "the hovered card rises into its headroom");
394 let ring = omp_tui::test_support::frame_cell_style(ui.frame(), column + 2, lifted_top as u16)
395 .foreground_color();
396 assert_ne!(
397 ring,
398 omp_tui::Theme::default().muted,
399 "the border cell over the pointer carries the glow"
400 );
401 assert!(lifted[rest_top + 4].contains('▀'), "the vacated row carries the shadow");
402 }
403
404 #[test]
405 fn keyboard_focus_lifts_a_card_and_enter_presses_it() {
406 let mut context = omp_tui::UiContext::default();
407 context.graphics = omp_tui::Graphics::Cells;
408 let mut ui = build_ui(Size::new(100, 18), context);
409 let rows = |ui: &omp_tui::Ui| -> Vec<String> {
410 (0..ui.frame().size().height)
411 .map(|y| omp_tui::test_support::frame_row_text(ui.frame(), y))
412 .collect()
413 };
414 let rest_top = rows(&ui)
415 .iter()
416 .position(|row| row.contains('╭'))
417 .expect("cards render a border row");
418
419 ui.handle_key(Key::Tab);
422 ui.tick(std::time::Duration::from_millis(400));
423 let focused_top = rows(&ui).iter().position(|row| row.contains('╭')).unwrap();
424 assert_eq!(focused_top + 1, rest_top, "keyboard focus lifts the card like hover");
425 assert_eq!(
426 ui.handle_key(Key::Enter),
427 omp_tui::UiEvent::Pressed(PROVIDERS[0].id.into()),
428 "enter presses the focused card"
429 );
430 }
431
432 #[test]
433 fn arrow_navigation_stays_anchored_and_presses_the_selected_card() {
434 let mut context = omp_tui::UiContext::default();
435 context.graphics = omp_tui::Graphics::Cells;
436 let mut ui = build_ui(Size::new(100, 18), context);
437 let rows = |ui: &omp_tui::Ui| -> Vec<String> {
438 (0..ui.frame().size().height)
439 .map(|y| omp_tui::test_support::frame_row_text(ui.frame(), y))
440 .collect()
441 };
442
443 ui.handle_key(Key::Tab);
447 ui.handle_key(Key::Right);
448 ui.tick(std::time::Duration::from_millis(400));
449 let grid = rows(&ui);
450 assert!(
451 grid.iter().any(|row| row.contains(PROVIDERS[0].name)),
452 "arrow navigation keeps the viewport anchored at the top"
453 );
454 assert_eq!(
455 ui.handle_key(Key::Enter),
456 omp_tui::UiEvent::Pressed(PROVIDERS[1].id.into()),
457 "enter presses the arrow-selected card"
458 );
459 }
460
461 #[test]
462 fn arrow_down_reaches_the_card_below() {
463 let mut context = omp_tui::UiContext::default();
464 context.graphics = omp_tui::Graphics::Cells;
465 let mut ui = build_ui(Size::new(100, 18), context);
466 ui.handle_key(Key::Tab);
470 ui.handle_key(Key::Down);
471 assert_eq!(
472 ui.handle_key(Key::Enter),
473 omp_tui::UiEvent::Pressed(PROVIDERS[5].id.into()),
474 "down picks the card in the row below"
475 );
476 }
477}