use crossterm::event::{KeyCode, KeyEvent};
use ratatui::{
buffer::Buffer,
layout::Rect,
style::Style,
text::{Line, Span},
widgets::{Block, Paragraph, StatefulWidget, Widget, Wrap},
};
use crate::config::{DiagramsEnabled, ImagesEnabled, RemoteImagePolicy, Theme};
use crate::terminal::Capabilities;
use crate::ui::cap_summary::{cap_row_height, render_cap_row as shared_render_cap_row, CapSummary};
use crate::ui::controls::{self, Control, ControlEvent, ControlInput, ControlValue};
use crate::ui::overlay_nav::next_focusable_wrapping;
use crate::ui::scroll_container::{
centered_rect_for_content, compute_pad_h, draw_frame, ContentSize, FrameOpts, ModalKind,
ScrollContainerState, MAX_PAD_H,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WelcomeFocus {
Theme,
Images,
RemoteImages,
Diagrams,
VimMotions,
CheckUpdates,
ShowAgain,
Save,
}
const FOCUS_ORDER: [WelcomeFocus; 8] = [
WelcomeFocus::Theme,
WelcomeFocus::Diagrams,
WelcomeFocus::Images,
WelcomeFocus::RemoteImages,
WelcomeFocus::VimMotions,
WelcomeFocus::CheckUpdates,
WelcomeFocus::ShowAgain,
WelcomeFocus::Save,
];
impl WelcomeFocus {
fn order_index(self) -> usize {
FOCUS_ORDER
.iter()
.position(|f| *f == self)
.expect("every WelcomeFocus must appear in FOCUS_ORDER")
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum WelcomeResponse {
Continue,
OpenThemePicker,
Save,
Cancel,
}
pub struct WelcomeState {
pub focused: WelcomeFocus,
pub images: ImagesEnabled,
pub remote: RemoteImagePolicy,
pub diagrams: DiagramsEnabled,
pub use_vim: bool,
pub check_for_updates: bool,
pub dont_show_again: bool,
pub dismissable: bool,
pub image_capable: bool,
pub full_color: bool,
pre_cascade_remote: Option<RemoteImagePolicy>,
pub scroll_state: ScrollContainerState,
pub theme_button_rect: Option<Rect>,
pub esc_button_rect: Option<Rect>,
pub images_rect: Option<Rect>,
pub remote_rect: Option<Rect>,
pub diagrams_rect: Option<Rect>,
pub vim_rect: Option<Rect>,
pub check_updates_rect: Option<Rect>,
pub show_again_rect: Option<Rect>,
pub save_button_rect: Option<Rect>,
focus_offsets: [u16; FOCUS_ORDER.len()],
cap_summary: CapSummary,
}
impl WelcomeState {
pub fn new(
caps: &Capabilities,
images: ImagesEnabled,
remote: RemoteImagePolicy,
diagrams: DiagramsEnabled,
use_vim: bool,
check_for_updates: bool,
) -> Self {
let full_color = caps.full_color();
let (images, diagrams) = if full_color {
(images, diagrams)
} else {
(ImagesEnabled::Never, DiagramsEnabled::Never)
};
let mut state = Self {
focused: WelcomeFocus::Theme,
images,
remote,
diagrams,
use_vim,
check_for_updates,
dont_show_again: true,
dismissable: false,
image_capable: caps.image_protocol.is_some() && full_color,
full_color,
pre_cascade_remote: None,
scroll_state: ScrollContainerState::default(),
theme_button_rect: None,
esc_button_rect: None,
images_rect: None,
remote_rect: None,
diagrams_rect: None,
vim_rect: None,
check_updates_rect: None,
show_again_rect: None,
save_button_rect: None,
focus_offsets: [0; FOCUS_ORDER.len()],
cap_summary: CapSummary::from_caps(caps),
};
if state.row_disabled(state.focused) {
state.step_focus(1);
}
state
}
pub fn with_dismissable(mut self, dismissable: bool) -> Self {
self.dismissable = dismissable;
self
}
fn remote_locked_by_images(&self) -> bool {
matches!(self.images, ImagesEnabled::Never)
}
fn row_disabled(&self, row: WelcomeFocus) -> bool {
match row {
WelcomeFocus::Theme => !self.full_color,
WelcomeFocus::Images | WelcomeFocus::Diagrams => !self.image_capable,
WelcomeFocus::RemoteImages => !self.image_capable || self.remote_locked_by_images(),
_ => false,
}
}
fn step_focus(&mut self, delta: i32) {
let cur = FOCUS_ORDER
.iter()
.position(|f| *f == self.focused)
.unwrap_or(0);
if let Some(i) =
next_focusable_wrapping(&FOCUS_ORDER, cur, delta, |f| !self.row_disabled(*f))
{
self.focused = FOCUS_ORDER[i];
self.scroll_state.ensure_visible(self.focus_offsets[i]);
}
}
fn apply_input(&mut self, input: ControlInput) {
let pill = Control::Pill(controls::ASK_ALWAYS_NEVER);
match self.focused {
WelcomeFocus::Images => {
if let ControlEvent::Changed(ControlValue::Choice(i)) = pill.apply(
ControlValue::Choice(pill_index(&IMAGES_ORDER, self.images)),
input,
) {
self.set_images(pill_value(&IMAGES_ORDER, i));
}
}
WelcomeFocus::RemoteImages if !self.remote_locked_by_images() => {
if let ControlEvent::Changed(ControlValue::Choice(i)) = pill.apply(
ControlValue::Choice(pill_index(&REMOTE_ORDER, self.remote)),
input,
) {
self.remote = pill_value(&REMOTE_ORDER, i);
}
}
WelcomeFocus::Diagrams => {
if let ControlEvent::Changed(ControlValue::Choice(i)) = pill.apply(
ControlValue::Choice(pill_index(&DIAGRAMS_ORDER, self.diagrams)),
input,
) {
self.diagrams = pill_value(&DIAGRAMS_ORDER, i);
}
}
WelcomeFocus::VimMotions => {
if let ControlEvent::Changed(ControlValue::Toggle(v)) =
Control::Toggle.apply(ControlValue::Toggle(self.use_vim), input)
{
self.use_vim = v;
}
}
WelcomeFocus::CheckUpdates => {
if let ControlEvent::Changed(ControlValue::Toggle(v)) =
Control::Toggle.apply(ControlValue::Toggle(self.check_for_updates), input)
{
self.check_for_updates = v;
}
}
WelcomeFocus::ShowAgain => {
if let ControlEvent::Changed(ControlValue::Toggle(v)) =
Control::Toggle.apply(ControlValue::Toggle(self.dont_show_again), input)
{
self.dont_show_again = v;
}
}
_ => {}
}
}
fn set_images(&mut self, next: ImagesEnabled) {
let was_never = matches!(self.images, ImagesEnabled::Never);
self.remote = controls::apply_images_cascade(
next,
was_never,
self.remote,
&mut self.pre_cascade_remote,
);
self.images = next;
}
pub fn handle_key(&mut self, key: &KeyEvent) -> WelcomeResponse {
if self.scroll_state.handle_paging_key(key) {
return WelcomeResponse::Continue;
}
match key.code {
KeyCode::Tab | KeyCode::Down => {
self.step_focus(1);
WelcomeResponse::Continue
}
KeyCode::BackTab | KeyCode::Up => {
self.step_focus(-1);
WelcomeResponse::Continue
}
KeyCode::Enter | KeyCode::Char(' ') if self.focused == WelcomeFocus::Theme => {
if self.row_disabled(WelcomeFocus::Theme) {
WelcomeResponse::Continue
} else {
WelcomeResponse::OpenThemePicker
}
}
KeyCode::Enter | KeyCode::Char(' ') if self.focused == WelcomeFocus::Save => {
WelcomeResponse::Save
}
KeyCode::Esc if self.dismissable => WelcomeResponse::Cancel,
KeyCode::Esc => WelcomeResponse::Continue,
_ => {
if let Some(input) = controls::control_input_for(key.code) {
self.apply_input(input);
}
WelcomeResponse::Continue
}
}
}
pub fn handle_wheel(&mut self, delta: i32) {
self.scroll_state.scroll_by(delta);
}
pub fn handle_click(&mut self, col: u16, row: u16) -> WelcomeResponse {
if rect_contains(self.esc_button_rect, col, row) {
return WelcomeResponse::Cancel;
}
if rect_contains(self.theme_button_rect, col, row) {
self.focused = WelcomeFocus::Theme;
return WelcomeResponse::OpenThemePicker;
}
if rect_contains(self.save_button_rect, col, row) {
self.focused = WelcomeFocus::Save;
return WelcomeResponse::Save;
}
if rect_contains(self.show_again_rect, col, row) {
self.focused = WelcomeFocus::ShowAgain;
self.apply_input(ControlInput::Activate);
return WelcomeResponse::Continue;
}
if rect_contains(self.vim_rect, col, row) {
self.focused = WelcomeFocus::VimMotions;
self.apply_input(ControlInput::Activate);
return WelcomeResponse::Continue;
}
if rect_contains(self.check_updates_rect, col, row) {
self.focused = WelcomeFocus::CheckUpdates;
self.apply_input(ControlInput::Activate);
return WelcomeResponse::Continue;
}
if self.image_capable {
if rect_contains(self.images_rect, col, row) {
self.focused = WelcomeFocus::Images;
self.apply_input(ControlInput::Activate);
return WelcomeResponse::Continue;
}
if !self.remote_locked_by_images() && rect_contains(self.remote_rect, col, row) {
self.focused = WelcomeFocus::RemoteImages;
self.apply_input(ControlInput::Activate);
return WelcomeResponse::Continue;
}
if rect_contains(self.diagrams_rect, col, row) {
self.focused = WelcomeFocus::Diagrams;
self.apply_input(ControlInput::Activate);
return WelcomeResponse::Continue;
}
}
WelcomeResponse::Continue
}
}
pub struct WelcomeView<'a> {
pub theme: &'a Theme,
pub theme_name: &'a str,
}
const CONTENT_WIDTH: u16 = 64;
const CONTROL_COL: u16 = 22;
const QUICK_START_TEXT: &str = "edamame is a Markdown editor for your terminal, featuring:\n\
• 3 modes — PREVIEW for viewing; EDIT renders everything but \
the line the cursor is on; RAW is unformatted \n\
• Mouse, image, and Mermaid diagram support, depending on your terminal's capabilities\n\
• GitHub Flavored Markdown, including tables, task lists, and more, plus highlights\n\
• Diff mode — review external file changes hunk by hunk\n\
• Command palette for access to commands and settings (Ctrl-P)\n\
• Vim mode — optional Vim-style editing (see docs for what's supported)";
const DEGRADED_HINT: &str = "✗ — Consider upgrading to a modern terminal, \
such as kitty, wezterm, or ghostty, for a better experience.";
const NO_TRUECOLOR_HINT: &str = "✗ — Images and diagrams have been turned off \
and theme switching is unavailable due to no 24-bit color support.";
fn wrapped_para_rows(text: &str, width: u16) -> u16 {
if width == 0 {
return text.split('\n').count().max(1) as u16;
}
Paragraph::new(text)
.wrap(Wrap { trim: false })
.line_count(width)
.min(u16::MAX as usize) as u16
}
impl<'a> StatefulWidget for WelcomeView<'a> {
type State = WelcomeState;
fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
let degraded = !state.cap_summary.all_ok();
let modal_width = CONTENT_WIDTH.saturating_add(2 * MAX_PAD_H).min(area.width);
let pad_h = compute_pad_h(modal_width, CONTENT_WIDTH, MAX_PAD_H);
let body_width = modal_width.saturating_sub(2 * pad_h);
let para_inner_w = body_width.saturating_sub(2);
let para_rows = wrapped_para_rows(QUICK_START_TEXT, para_inner_w);
let no_truecolor_rows = if state.full_color {
0
} else {
wrapped_para_rows(NO_TRUECOLOR_HINT, para_inner_w)
};
let hint_rows = if degraded {
wrapped_para_rows(DEGRADED_HINT, para_inner_w)
} else {
0
};
let cap_row_heights: Vec<u16> = state
.cap_summary
.rows
.iter()
.map(|r| cap_row_height(r, body_width))
.collect();
let cap_rows: u16 = cap_row_heights.iter().sum();
let natural_height = 1
+ para_rows
+ 1
+ 1
+ cap_rows
+ no_truecolor_rows
+ hint_rows
+ 1
+ 2
+ 9
+ 3
+ 3
+ 1
+ 1
+ 1;
let content = ContentSize {
width: CONTENT_WIDTH,
height: natural_height,
pinned_top: 0,
pinned_bottom: 0,
..Default::default()
};
let modal_area = centered_rect_for_content(content, area);
let layout = draw_frame(
modal_area,
buf,
FrameOpts {
title: "Welcome to edamame",
kind: ModalKind::Normal,
show_close_hint: state.dismissable,
content,
theme: self.theme,
},
);
state.esc_button_rect = layout.esc_hit_rect;
let body = layout.body;
if body.height == 0 || body.width == 0 {
return;
}
state.scroll_state.observe(natural_height, body.height);
let scroll = state.scroll_state.scroll;
let scratch_rect = Rect {
x: 0,
y: 0,
width: body.width,
height: natural_height,
};
let mut scratch = Buffer::empty(scratch_rect);
Block::default()
.style(self.theme.modal_bg)
.render(scratch_rect, &mut scratch);
let normal_style = Style::default()
.fg(self.theme.palette.text)
.bg(self.theme.palette.surface_elevated);
let muted_style = Style::default()
.fg(self.theme.palette.text_muted)
.bg(self.theme.palette.surface_elevated);
let ok_style = Style::default()
.fg(self.theme.palette.success)
.bg(self.theme.palette.surface_elevated);
let warn_style = Style::default()
.fg(self.theme.palette.warning)
.bg(self.theme.palette.surface_elevated);
let body_x: u16 = 0;
let body_w = body.width;
let mut y: u16 = 0;
render_label(
&mut scratch,
body_x,
y,
body_w,
"Getting started",
self.theme,
);
y += 1;
for row in 0..para_rows {
Paragraph::new("").style(self.theme.modal_bg).render(
Rect {
x: body_x,
y: y + row,
width: body_w,
height: 1,
},
&mut scratch,
);
}
let para_area = Rect {
x: body_x + 2,
y,
width: para_inner_w,
height: para_rows,
};
Paragraph::new(QUICK_START_TEXT)
.wrap(Wrap { trim: false })
.style(normal_style)
.render(para_area, &mut scratch);
y += para_rows + 1;
render_label(
&mut scratch,
body_x,
y,
body_w,
"Terminal capabilities",
self.theme,
);
y += 1;
for (row, height) in state.cap_summary.rows.iter().zip(&cap_row_heights) {
shared_render_cap_row(
&mut scratch,
body_x,
y,
body_w,
row,
self.theme,
ok_style,
warn_style,
);
y += height;
}
if no_truecolor_rows > 0 {
for row in 0..no_truecolor_rows {
Paragraph::new("").style(self.theme.modal_bg).render(
Rect {
x: body_x,
y: y + row,
width: body_w,
height: 1,
},
&mut scratch,
);
}
Paragraph::new(NO_TRUECOLOR_HINT)
.wrap(Wrap { trim: false })
.style(warn_style)
.render(
Rect {
x: body_x,
y,
width: para_inner_w,
height: no_truecolor_rows,
},
&mut scratch,
);
y += no_truecolor_rows;
}
if degraded {
for row in 0..hint_rows {
Paragraph::new("").style(self.theme.modal_bg).render(
Rect {
x: body_x,
y: y + row,
width: body_w,
height: 1,
},
&mut scratch,
);
}
Paragraph::new(DEGRADED_HINT)
.wrap(Wrap { trim: false })
.style(warn_style)
.render(
Rect {
x: body_x,
y,
width: para_inner_w,
height: hint_rows,
},
&mut scratch,
);
y += hint_rows;
}
Paragraph::new("").style(self.theme.modal_bg).render(
Rect {
x: body_x,
y,
width: body_w,
height: 1,
},
&mut scratch,
);
y += 1;
let theme_disabled = state.row_disabled(WelcomeFocus::Theme);
let theme_focused = state.focused == WelcomeFocus::Theme && !theme_disabled;
Paragraph::new("").style(self.theme.modal_bg).render(
Rect {
x: body_x,
y,
width: body_w,
height: 1,
},
&mut scratch,
);
let label_col_w = CONTROL_COL.min(body_w) as usize;
Paragraph::new(Line::from(Span::styled(
format!("{:<label_col_w$}", "Choose theme"),
controls::control_label_style(theme_focused, theme_disabled, self.theme),
)))
.style(self.theme.modal_bg)
.render(
Rect {
x: body_x,
y,
width: CONTROL_COL.min(body_w),
height: 1,
},
&mut scratch,
);
let theme_button_label = format!("{} ▸", self.theme_name);
let theme_button_rect = crate::ui::button_row::render_button_at(
Rect {
x: body_x + CONTROL_COL,
y,
width: body_w.saturating_sub(CONTROL_COL),
height: 1,
},
&mut scratch,
crate::ui::button_row::Button::bracketed(&theme_button_label),
theme_focused,
theme_disabled,
self.theme,
);
state.theme_button_rect = (!theme_disabled).then_some(theme_button_rect);
state.focus_offsets[WelcomeFocus::Theme.order_index()] = y;
y += 2;
let pill_w = controls::pill_width(controls::ASK_ALWAYS_NEVER) as u16;
state.focus_offsets[WelcomeFocus::Diagrams.order_index()] = y;
state.diagrams_rect = render_control_row(
&mut scratch,
scratch_rect,
y,
"Show diagrams",
controls::pill_spans(
controls::ASK_ALWAYS_NEVER,
pill_index(&DIAGRAMS_ORDER, state.diagrams),
state.focused == WelcomeFocus::Diagrams,
!state.image_capable,
self.theme,
),
pill_w,
state.focused == WelcomeFocus::Diagrams,
!state.image_capable,
self.theme,
);
y += 1;
render_explanation(
&mut scratch,
body_x,
y,
body_w,
"Render mermaid code blocks as inline diagrams.",
muted_style,
self.theme,
);
y += 2;
state.focus_offsets[WelcomeFocus::Images.order_index()] = y;
state.images_rect = render_control_row(
&mut scratch,
scratch_rect,
y,
"Show images",
controls::pill_spans(
controls::ASK_ALWAYS_NEVER,
pill_index(&IMAGES_ORDER, state.images),
state.focused == WelcomeFocus::Images,
!state.image_capable,
self.theme,
),
pill_w,
state.focused == WelcomeFocus::Images,
!state.image_capable,
self.theme,
);
y += 1;
render_explanation(
&mut scratch,
body_x,
y,
body_w,
"Render inline images using your terminal's image protocol.",
muted_style,
self.theme,
);
y += 2;
state.focus_offsets[WelcomeFocus::RemoteImages.order_index()] = y;
let remote_disabled = !state.image_capable || state.remote_locked_by_images();
state.remote_rect = render_control_row(
&mut scratch,
scratch_rect,
y,
"Show remote images",
controls::pill_spans(
controls::ASK_ALWAYS_NEVER,
pill_index(&REMOTE_ORDER, state.remote),
state.focused == WelcomeFocus::RemoteImages,
remote_disabled,
self.theme,
),
pill_w,
state.focused == WelcomeFocus::RemoteImages,
remote_disabled,
self.theme,
);
y += 1;
render_explanation(
&mut scratch,
body_x,
y,
body_w,
"Fetch images from http(s):// URLs",
muted_style,
self.theme,
);
y += 2;
let toggle_w = controls::toggle_width() as u16;
state.focus_offsets[WelcomeFocus::VimMotions.order_index()] = y;
state.vim_rect = render_control_row(
&mut scratch,
scratch_rect,
y,
"Vim mode",
controls::toggle_spans(
state.use_vim,
state.focused == WelcomeFocus::VimMotions,
false,
self.theme,
),
toggle_w,
state.focused == WelcomeFocus::VimMotions,
false,
self.theme,
);
y += 1;
render_explanation(
&mut scratch,
body_x,
y,
body_w,
"Enable Vim-style modal editing",
muted_style,
self.theme,
);
y += 2;
state.focus_offsets[WelcomeFocus::CheckUpdates.order_index()] = y;
state.check_updates_rect = render_control_row(
&mut scratch,
scratch_rect,
y,
"Check for updates",
controls::toggle_spans(
state.check_for_updates,
state.focused == WelcomeFocus::CheckUpdates,
false,
self.theme,
),
toggle_w,
state.focused == WelcomeFocus::CheckUpdates,
false,
self.theme,
);
y += 1;
render_explanation(
&mut scratch,
body_x,
y,
body_w,
"Check GitHub for a new release at startup",
muted_style,
self.theme,
);
y += 2;
state.focus_offsets[WelcomeFocus::ShowAgain.order_index()] = y;
state.show_again_rect = render_control_row(
&mut scratch,
scratch_rect,
y,
"Don't show this again",
controls::toggle_spans(
state.dont_show_again,
state.focused == WelcomeFocus::ShowAgain,
false,
self.theme,
),
toggle_w,
state.focused == WelcomeFocus::ShowAgain,
false,
self.theme,
);
y += 2;
let save_focused = state.focused == WelcomeFocus::Save;
let save_area = Rect {
x: body_x,
y,
width: body_w,
height: 1,
};
let save_rects = crate::ui::button_row::render_button_row(
save_area,
&mut scratch,
&["Save"],
if save_focused { 0 } else { usize::MAX },
self.theme,
);
state.save_button_rect = save_rects.into_iter().next();
state.focus_offsets[WelcomeFocus::Save.order_index()] = y;
let visible_h = body.height.min(natural_height.saturating_sub(scroll));
let visible_window = Rect {
x: body.x,
y: body.y,
width: body.width,
height: visible_h,
};
let trimmed_len = (visible_h as usize) * (body.width as usize);
let src_start = (scroll as usize) * (body.width as usize);
scratch.content = scratch.content[src_start..src_start + trimmed_len].to_vec();
scratch.area = visible_window;
buf.merge(&scratch);
state.theme_button_rect = translate_rect(state.theme_button_rect, body, scroll);
state.save_button_rect = translate_rect(state.save_button_rect, body, scroll);
state.show_again_rect = translate_rect(state.show_again_rect, body, scroll);
state.vim_rect = translate_rect(state.vim_rect, body, scroll);
state.check_updates_rect = translate_rect(state.check_updates_rect, body, scroll);
state.images_rect = translate_rect(state.images_rect, body, scroll);
state.remote_rect = translate_rect(state.remote_rect, body, scroll);
state.diagrams_rect = translate_rect(state.diagrams_rect, body, scroll);
if state.scroll_state.max_scroll() > 0 {
let bar_area = Rect {
x: layout.scrollbar_col,
y: body.y,
width: 1,
height: body.height,
};
crate::ui::scrollbar::render_for_scroll_state(
bar_area,
&state.scroll_state,
self.theme,
buf,
);
}
}
}
fn translate_rect(rect: Option<Rect>, body: Rect, scroll: u16) -> Option<Rect> {
let r = rect?;
let src_y0 = r.y;
let src_y1 = r.y.saturating_add(r.height);
let vis_y0 = src_y0.max(scroll);
let vis_y1 = src_y1.min(scroll.saturating_add(body.height));
if vis_y0 >= vis_y1 {
return None;
}
Some(Rect {
x: body.x + r.x,
y: body.y + (vis_y0 - scroll),
width: r.width,
height: vis_y1 - vis_y0,
})
}
const IMAGES_ORDER: [ImagesEnabled; 3] = [
ImagesEnabled::Ask,
ImagesEnabled::Always,
ImagesEnabled::Never,
];
const REMOTE_ORDER: [RemoteImagePolicy; 3] = [
RemoteImagePolicy::Ask,
RemoteImagePolicy::Always,
RemoteImagePolicy::Never,
];
const DIAGRAMS_ORDER: [DiagramsEnabled; 3] = [
DiagramsEnabled::Ask,
DiagramsEnabled::Always,
DiagramsEnabled::Never,
];
fn pill_index<T: PartialEq>(order: &[T], value: T) -> usize {
order.iter().position(|v| *v == value).unwrap_or(0)
}
fn pill_value<T: Copy>(order: &[T], i: usize) -> T {
order[i.min(order.len().saturating_sub(1))]
}
#[allow(clippy::too_many_arguments)]
fn render_control_row(
buf: &mut Buffer,
body: Rect,
y: u16,
label: &str,
spans: Vec<Span<'static>>,
width: u16,
focused: bool,
disabled: bool,
theme: &Theme,
) -> Option<Rect> {
let row_rect = Rect {
x: body.x,
y,
width: body.width,
height: 1,
};
Paragraph::new("")
.style(theme.modal_bg)
.render(row_rect, buf);
let label_col_w = CONTROL_COL.min(body.width) as usize;
let row_spans =
controls::control_row_spans(label, label_col_w, spans, focused, disabled, theme);
Paragraph::new(Line::from(row_spans))
.style(theme.modal_bg)
.render(row_rect, buf);
if body.width < CONTROL_COL + width {
return None;
}
if disabled {
None
} else {
Some(Rect {
x: body.x + CONTROL_COL,
y,
width,
height: 1,
})
}
}
fn render_explanation(
buf: &mut Buffer,
x: u16,
y: u16,
width: u16,
text: &str,
style: Style,
theme: &Theme,
) {
Paragraph::new("").style(theme.modal_bg).render(
Rect {
x,
y,
width,
height: 1,
},
buf,
);
Paragraph::new(Line::from(Span::styled(format!(" {text}"), style)))
.style(theme.modal_bg)
.render(
Rect {
x,
y,
width,
height: 1,
},
buf,
);
}
fn render_label(buf: &mut Buffer, x: u16, y: u16, width: u16, text: &str, theme: &Theme) {
let style = theme.modal_section_heading;
Paragraph::new("").style(theme.modal_bg).render(
Rect {
x,
y,
width,
height: 1,
},
buf,
);
Paragraph::new(Line::from(Span::styled(text.to_owned(), style)))
.style(theme.modal_bg)
.render(
Rect {
x,
y,
width,
height: 1,
},
buf,
);
}
fn rect_contains(rect: Option<Rect>, col: u16, row: u16) -> bool {
let Some(r) = rect else {
return false;
};
col >= r.x && col < r.x + r.width && row >= r.y && row < r.y + r.height
}
#[cfg(test)]
mod tests {
use super::*;
use crate::terminal::{Capabilities, ColorDepth, ImageProtocol};
fn caps_full() -> Capabilities {
Capabilities {
color_depth: ColorDepth::TrueColor,
mouse: true,
image_protocol: Some(ImageProtocol::KittyGraphics),
image_picker: None,
halfblocks_picker: None,
unicode_full: true,
keyboard_enhancement: true,
}
}
fn caps_no_images() -> Capabilities {
Capabilities {
image_protocol: None,
image_picker: None,
halfblocks_picker: None,
..caps_full()
}
}
fn caps_256_color() -> Capabilities {
Capabilities {
color_depth: ColorDepth::Ansi256,
..caps_full()
}
}
fn make_state(caps: &Capabilities) -> WelcomeState {
WelcomeState::new(
caps,
ImagesEnabled::Ask,
RemoteImagePolicy::Ask,
DiagramsEnabled::Ask,
false,
true,
)
}
fn esc(s: &mut WelcomeState) -> WelcomeResponse {
s.handle_key(&KeyEvent::new(
KeyCode::Esc,
crossterm::event::KeyModifiers::NONE,
))
}
#[test]
fn esc_is_inert_on_a_first_run_instance() {
let caps = caps_full();
let mut s = make_state(&caps);
assert!(!s.dismissable);
assert_eq!(esc(&mut s), WelcomeResponse::Continue);
}
#[test]
fn esc_cancels_an_on_demand_instance() {
let caps = caps_256_color();
let mut s = make_state(&caps).with_dismissable(true);
assert_eq!(s.images, ImagesEnabled::Never, "forced below truecolor");
assert_eq!(esc(&mut s), WelcomeResponse::Cancel);
}
#[test]
fn a_first_run_instance_renders_no_esc_affordance_to_click() {
let caps = caps_full();
let mut s = make_state(&caps);
render_rows(&mut s);
assert!(s.esc_button_rect.is_none());
let mut s = make_state(&caps).with_dismissable(true);
render_rows(&mut s);
let rect = s.esc_button_rect.expect("esc affordance is rendered");
assert_eq!(s.handle_click(rect.x, rect.y), WelcomeResponse::Cancel);
}
#[test]
fn tab_cycles_focus_skipping_disabled_rows_when_no_images() {
let caps = caps_no_images();
let mut s = make_state(&caps);
assert_eq!(s.focused, WelcomeFocus::Theme);
s.handle_key(&KeyEvent::new(
KeyCode::Tab,
crossterm::event::KeyModifiers::NONE,
));
assert_eq!(s.focused, WelcomeFocus::VimMotions);
}
#[test]
fn images_never_cascades_remote_to_never() {
let caps = caps_full();
let mut s = make_state(&caps);
s.focused = WelcomeFocus::Images;
s.handle_key(&KeyEvent::new(
KeyCode::Right,
crossterm::event::KeyModifiers::NONE,
));
s.handle_key(&KeyEvent::new(
KeyCode::Right,
crossterm::event::KeyModifiers::NONE,
));
assert!(matches!(s.images, ImagesEnabled::Never));
assert!(matches!(s.remote, RemoteImagePolicy::Never));
}
#[test]
fn flipping_images_back_restores_pre_cascade_remote() {
let caps = caps_full();
let mut s = make_state(&caps);
s.remote = RemoteImagePolicy::Always;
s.focused = WelcomeFocus::Images;
for _ in 0..2 {
s.handle_key(&KeyEvent::new(
KeyCode::Right,
crossterm::event::KeyModifiers::NONE,
));
}
assert!(matches!(s.images, ImagesEnabled::Never));
assert!(matches!(s.remote, RemoteImagePolicy::Never));
s.handle_key(&KeyEvent::new(
KeyCode::Right,
crossterm::event::KeyModifiers::NONE,
));
assert!(matches!(s.images, ImagesEnabled::Ask));
assert!(matches!(s.remote, RemoteImagePolicy::Always));
}
#[test]
fn save_button_enter_returns_save_response() {
let caps = caps_full();
let mut s = make_state(&caps);
s.focused = WelcomeFocus::Save;
let r = s.handle_key(&KeyEvent::new(
KeyCode::Enter,
crossterm::event::KeyModifiers::NONE,
));
assert_eq!(r, WelcomeResponse::Save);
}
#[test]
fn space_toggles_check_for_updates() {
let caps = caps_full();
let mut s = make_state(&caps);
assert!(s.check_for_updates, "opt-out, so on by default");
s.focused = WelcomeFocus::CheckUpdates;
s.handle_key(&KeyEvent::new(
KeyCode::Char(' '),
crossterm::event::KeyModifiers::NONE,
));
assert!(!s.check_for_updates);
s.handle_key(&KeyEvent::new(
KeyCode::Enter,
crossterm::event::KeyModifiers::NONE,
));
assert!(s.check_for_updates, "Enter toggles back on");
}
#[test]
fn space_toggles_vim_motions() {
let caps = caps_full();
let mut s = make_state(&caps);
assert!(!s.use_vim, "vim motions default off");
s.focused = WelcomeFocus::VimMotions;
s.handle_key(&KeyEvent::new(
KeyCode::Char(' '),
crossterm::event::KeyModifiers::NONE,
));
assert!(s.use_vim);
s.handle_key(&KeyEvent::new(
KeyCode::Enter,
crossterm::event::KeyModifiers::NONE,
));
assert!(!s.use_vim, "Enter toggles back off");
}
#[test]
fn toggle_arrows_are_direction_bound() {
let caps = caps_full();
let mut s = make_state(&caps);
s.focused = WelcomeFocus::VimMotions;
assert!(!s.use_vim);
s.handle_key(&KeyEvent::new(
KeyCode::Left,
crossterm::event::KeyModifiers::NONE,
));
assert!(!s.use_vim, "Left means off, even when already off");
s.handle_key(&KeyEvent::new(
KeyCode::Right,
crossterm::event::KeyModifiers::NONE,
));
assert!(s.use_vim, "Right means on");
s.handle_key(&KeyEvent::new(
KeyCode::Right,
crossterm::event::KeyModifiers::NONE,
));
assert!(s.use_vim, "Right when already on is a no-op");
s.handle_key(&KeyEvent::new(
KeyCode::Left,
crossterm::event::KeyModifiers::NONE,
));
assert!(!s.use_vim, "Left means off");
}
#[test]
fn below_truecolor_disables_theme_and_image_rows() {
let caps = caps_256_color();
let s = make_state(&caps);
assert!(!s.full_color);
assert!(
!s.image_capable,
"an image protocol without truecolor is not usable"
);
for row in [
WelcomeFocus::Theme,
WelcomeFocus::Images,
WelcomeFocus::RemoteImages,
WelcomeFocus::Diagrams,
] {
assert!(s.row_disabled(row), "{row:?} must be disabled");
}
assert_eq!(s.focused, WelcomeFocus::VimMotions);
for row in [
WelcomeFocus::VimMotions,
WelcomeFocus::ShowAgain,
WelcomeFocus::Save,
] {
assert!(!s.row_disabled(row), "{row:?} must stay interactive");
}
}
#[test]
fn below_truecolor_forces_images_and_diagrams_off_but_not_remote() {
let s = make_state(&caps_256_color());
assert_eq!(s.images, ImagesEnabled::Never);
assert_eq!(s.diagrams, DiagramsEnabled::Never);
assert_eq!(s.remote, RemoteImagePolicy::Ask);
}
#[test]
fn truecolor_leaves_the_incoming_tristate_values_alone() {
let s = make_state(&caps_full());
assert_eq!(s.images, ImagesEnabled::Ask);
assert_eq!(s.diagrams, DiagramsEnabled::Ask);
assert_eq!(s.remote, RemoteImagePolicy::Ask);
}
#[test]
fn below_truecolor_theme_row_cannot_open_the_picker() {
let caps = caps_256_color();
let mut s = make_state(&caps);
s.focused = WelcomeFocus::Theme;
let r = s.handle_key(&KeyEvent::new(
KeyCode::Enter,
crossterm::event::KeyModifiers::NONE,
));
assert_eq!(r, WelcomeResponse::Continue);
}
#[test]
fn truecolor_opens_focused_on_the_theme_row() {
let caps = caps_full();
let s = make_state(&caps);
assert!(s.full_color);
assert!(s.image_capable);
assert_eq!(s.focused, WelcomeFocus::Theme);
}
const RENDER_AREA: Rect = Rect {
x: 0,
y: 0,
width: 90,
height: 60,
};
fn render_rows(state: &mut WelcomeState) -> Vec<String> {
let theme: &'static Theme = Box::leak(Box::new(Theme::default()));
let area = RENDER_AREA;
let mut buf = Buffer::empty(area);
WelcomeView {
theme,
theme_name: "Edamame",
}
.render(area, &mut buf, state);
(0..area.height)
.map(|y| {
(0..area.width)
.map(|x| buf[(x, y)].symbol())
.collect::<String>()
})
.collect()
}
#[test]
fn below_truecolor_renders_no_interactive_theme_or_image_rects() {
let mut live = make_state(&caps_full());
render_rows(&mut live);
let button = live
.theme_button_rect
.expect("truecolor render publishes a theme-button rect");
assert_eq!(
live.handle_click(button.x, button.y),
WelcomeResponse::OpenThemePicker,
"sanity: a click on the live button opens the picker"
);
let mut s = make_state(&caps_256_color());
render_rows(&mut s);
assert!(
s.theme_button_rect.is_none(),
"a disabled theme button reports no hit rect"
);
assert!(s.images_rect.is_none());
assert!(s.remote_rect.is_none());
assert!(s.diagrams_rect.is_none());
assert!(s.vim_rect.is_some());
assert!(s.save_button_rect.is_some());
for row in 0..RENDER_AREA.height {
for col in 0..RENDER_AREA.width {
assert_ne!(
s.handle_click(col, row),
WelcomeResponse::OpenThemePicker,
"click at ({col}, {row}) opened the picker on a disabled theme row"
);
}
}
}
#[test]
fn below_truecolor_explains_itself_exactly_once() {
let mut s = make_state(&caps_256_color());
let rows = render_rows(&mut s);
let text = rows.join("\n");
assert_eq!(
text.matches("Consider upgrading").count(),
1,
"upgrade hint renders exactly once:\n{}",
rows.join("\n")
);
assert_eq!(
text.matches("have been turned off").count(),
1,
"forced-off note renders exactly once:\n{}",
rows.join("\n")
);
}
#[test]
fn truecolor_renders_a_live_theme_button_and_no_degraded_hint() {
let mut s = make_state(&caps_full());
let rows = render_rows(&mut s);
assert!(!rows.join(" ").contains("Consider upgrading"));
assert!(
!rows.join(" ").contains("have been turned off"),
"nothing was forced off, so the note must not render"
);
assert!(s.theme_button_rect.is_some());
assert!(s.images_rect.is_some());
assert!(s.diagrams_rect.is_some());
}
#[test]
fn every_focus_variant_is_ordered() {
for (i, f) in FOCUS_ORDER.iter().enumerate() {
assert_eq!(f.order_index(), i, "{f:?} resolves to its FOCUS_ORDER slot");
}
}
#[test]
fn space_cycles_focused_tristate_row() {
let caps = caps_full();
let mut s = make_state(&caps);
s.focused = WelcomeFocus::Images;
s.handle_key(&KeyEvent::new(
KeyCode::Char(' '),
crossterm::event::KeyModifiers::NONE,
));
assert!(matches!(s.images, ImagesEnabled::Always));
s.handle_key(&KeyEvent::new(
KeyCode::Char(' '),
crossterm::event::KeyModifiers::NONE,
));
assert!(matches!(s.images, ImagesEnabled::Never));
assert!(matches!(s.remote, RemoteImagePolicy::Never));
}
#[test]
fn letter_s_no_longer_saves() {
let caps = caps_full();
let mut s = make_state(&caps);
let r = s.handle_key(&KeyEvent::new(
KeyCode::Char('s'),
crossterm::event::KeyModifiers::NONE,
));
assert_eq!(r, WelcomeResponse::Continue);
}
#[test]
fn theme_enter_opens_picker() {
let caps = caps_full();
let mut s = make_state(&caps);
s.focused = WelcomeFocus::Theme;
let r = s.handle_key(&KeyEvent::new(
KeyCode::Enter,
crossterm::event::KeyModifiers::NONE,
));
assert_eq!(r, WelcomeResponse::OpenThemePicker);
}
#[test]
fn wheel_scrolls_body() {
let caps = caps_full();
let mut s = make_state(&caps);
s.scroll_state.observe(40, 20);
s.handle_wheel(3);
assert_eq!(s.scroll_state.scroll, 3);
s.handle_wheel(-1);
assert_eq!(s.scroll_state.scroll, 2);
}
#[test]
fn pgdown_scrolls_without_moving_focus() {
let caps = caps_full();
let mut s = make_state(&caps);
s.scroll_state.observe(40, 10);
let before = s.focused;
s.handle_key(&KeyEvent::new(
KeyCode::PageDown,
crossterm::event::KeyModifiers::NONE,
));
assert_eq!(s.focused, before, "PgDn must not move focus");
assert_eq!(s.scroll_state.scroll, 10);
}
}