use std::path::{Path, PathBuf};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::{
buffer::Buffer,
layout::{Alignment, Rect},
text::{Line, Span},
widgets::{Paragraph, StatefulWidget, Widget, Wrap},
};
use crate::config::Theme;
use crate::ui::button_row::{button_row_width, footer_row_count, render_button_row};
use crate::ui::controls::{
self, control_input_for, control_row_spans, cycle_index, input_delta, pill_spans, pill_width,
toggle_spans, toggle_width, Control, ControlEvent, ControlInput, ControlValue,
};
use crate::ui::cursor::text_field_spans;
use crate::ui::overlay_nav::next_focusable_wrapping;
use crate::ui::sanitize_paste;
use crate::ui::scroll_container::{
centered_rect_for_content, compute_pad_h, draw_frame, wrapped_rows, ContentSize, FrameOpts,
ModalKind, ScrollContainerState, MAX_PAD_H, PROSE_CONTENT_WIDTH, VERTICAL_CHROME_ROWS,
};
const TITLE_FIELD_WIDTH: usize = 30;
const TITLE_CHAR_CAP: usize = 120;
const NOTE_INDENT: &str = " ";
const IMAGES_NOTE_ON: &str = "Inline images as data:URIs";
const IMAGES_NOTE_OFF: &str = "Leave images as links";
const DIAGRAMS_NOTE_ON: &str = "Inline diagrams as SVG";
const DIAGRAMS_NOTE_OFF: &str = "Leave diagrams as code";
const OPTION_BUTTONS: &[&str] = &["Export"];
const FOOTER_ROWS: u16 = 2;
const OVERWRITE_BUTTONS: &[&str] = &["Overwrite", "Cancel"];
const ERROR_BUTTONS: &[&str] = &["Back"];
const OPEN_FOLDER_BUTTON: &str = "Open folder";
const FRAME_TITLE: &str = "Export";
const LIST_INDENT: &str = " ";
const MARKER_SELECTED: &str = "● ";
const MARKER_UNSELECTED: &str = "○ ";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExportFormat {
pub label: String,
pub open_result: String,
}
impl ExportFormat {
pub fn html() -> Self {
Self {
label: "HTML".to_owned(),
open_result: "Open in browser".to_owned(),
}
}
pub fn custom(name: &str) -> Self {
Self {
label: name.trim().to_owned(),
open_result: "Open file".to_owned(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ExportPhase {
Options,
ConfirmOverwrite,
Exporting,
Success,
Error,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum OptFocus {
Title,
Images,
Diagrams,
Stylesheet,
Format,
Export,
}
impl OptFocus {
const ORDER: [OptFocus; 6] = [
OptFocus::Title,
OptFocus::Images,
OptFocus::Diagrams,
OptFocus::Stylesheet,
OptFocus::Format,
OptFocus::Export,
];
fn step(self, delta: i32) -> Self {
let cur = Self::ORDER.iter().position(|f| *f == self).unwrap_or(0);
next_focusable_wrapping(&Self::ORDER, cur, delta, |_| true)
.map(|i| Self::ORDER[i])
.unwrap_or(self)
}
fn next(self) -> Self {
self.step(1)
}
fn prev(self) -> Self {
self.step(-1)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum FormRow {
Spacer,
Title,
Images,
ImagesNote,
Diagrams,
DiagramsNote,
Stylesheet,
FormatLabel,
Format(usize),
}
fn form_rows(format_count: usize) -> Vec<FormRow> {
let mut rows = vec![
FormRow::Title,
FormRow::Spacer,
FormRow::Images,
FormRow::ImagesNote,
FormRow::Spacer,
FormRow::Diagrams,
FormRow::DiagramsNote,
FormRow::Spacer,
FormRow::Stylesheet,
FormRow::Spacer,
FormRow::FormatLabel,
];
rows.extend((0..format_count).map(FormRow::Format));
rows
}
fn focus_reveal_rows(rows: &[FormRow], focus: OptFocus, format_idx: usize) -> Vec<u16> {
let row_of = |want: FormRow| rows.iter().position(|r| *r == want).map(|i| i as u16);
let wanted: [Option<FormRow>; 2] = match focus {
OptFocus::Title => [None, Some(FormRow::Title)],
OptFocus::Images => [Some(FormRow::ImagesNote), Some(FormRow::Images)],
OptFocus::Diagrams => [Some(FormRow::DiagramsNote), Some(FormRow::Diagrams)],
OptFocus::Stylesheet => [None, Some(FormRow::Stylesheet)],
OptFocus::Format => [None, Some(FormRow::Format(format_idx))],
OptFocus::Export => [None, None],
};
wanted.into_iter().flatten().filter_map(row_of).collect()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExportChoices {
pub title: Option<String>,
pub inline_images: bool,
pub render_diagrams: bool,
pub stylesheet: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ExportResponse {
Continue,
Cancelled,
Submit(ExportChoices),
ProceedOverwrite,
OpenResult,
OpenFolder,
}
pub struct ExportState {
pub phase: ExportPhase,
pub formats: Vec<ExportFormat>,
pub format_idx: usize,
pub title: String,
pub inline_images: bool,
pub render_diagrams: bool,
pub stylesheets: Vec<(String, String)>,
pub stylesheet_idx: usize,
pub submitted_title: Option<String>,
pub target: Option<PathBuf>,
pub result_path: Option<PathBuf>,
pub error_message: Option<String>,
focus: OptFocus,
btn_focus: usize,
pub esc_button_rect: Option<Rect>,
pub scroll_state: ScrollContainerState,
format_rects: Vec<(usize, Rect)>,
title_rect: Option<Rect>,
images_rect: Option<Rect>,
diagrams_rect: Option<Rect>,
stylesheet_rect: Option<Rect>,
export_button_rect: Option<Rect>,
msg_button_rects: Vec<Rect>,
}
impl ExportState {
pub fn new(
formats: Vec<ExportFormat>,
title: String,
inline_images: bool,
render_diagrams: bool,
stylesheets: Vec<(String, String)>,
stylesheet_idx: usize,
) -> Self {
Self {
phase: ExportPhase::Options,
formats,
format_idx: 0,
title,
inline_images,
render_diagrams,
stylesheets,
stylesheet_idx,
submitted_title: None,
target: None,
result_path: None,
error_message: None,
focus: OptFocus::Title,
btn_focus: 0,
esc_button_rect: None,
scroll_state: ScrollContainerState::default(),
format_rects: Vec::new(),
title_rect: None,
images_rect: None,
diagrams_rect: None,
stylesheet_rect: None,
export_button_rect: None,
msg_button_rects: Vec::new(),
}
}
fn clear_hit_rects(&mut self) {
self.esc_button_rect = None;
self.format_rects.clear();
self.title_rect = None;
self.images_rect = None;
self.diagrams_rect = None;
self.stylesheet_rect = None;
self.export_button_rect = None;
self.msg_button_rects.clear();
}
pub fn handle_wheel(&mut self, delta: i32) {
if self.phase == ExportPhase::Options {
self.scroll_state.scroll_by(delta);
}
}
fn open_result_label(&self) -> String {
self.formats
.get(self.format_idx)
.map(|f| f.open_result.clone())
.unwrap_or_else(|| "Open file".to_owned())
}
pub fn enter_confirm_overwrite(&mut self, target: PathBuf) {
self.target = Some(target);
self.phase = ExportPhase::ConfirmOverwrite;
self.btn_focus = 0;
}
pub fn enter_exporting(&mut self, target: PathBuf) {
self.target = Some(target);
self.phase = ExportPhase::Exporting;
}
pub fn set_success(&mut self, path: PathBuf) {
self.result_path = Some(path);
self.phase = ExportPhase::Success;
self.btn_focus = 0;
}
pub fn set_error(&mut self, message: String) {
self.error_message = Some(message);
self.phase = ExportPhase::Error;
self.btn_focus = 0;
}
pub fn handle_key(&mut self, key: &KeyEvent) -> ExportResponse {
if key
.modifiers
.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT)
{
return ExportResponse::Continue;
}
match self.phase {
ExportPhase::Options => self.handle_options_key(key),
ExportPhase::ConfirmOverwrite => self.handle_button_key(key, 2, |idx| {
if idx == 0 {
ExportResponse::ProceedOverwrite
} else {
ExportResponse::Cancelled
}
}),
ExportPhase::Exporting => match key.code {
KeyCode::Esc => ExportResponse::Cancelled,
_ => ExportResponse::Continue,
},
ExportPhase::Success => self.handle_button_key(key, 2, |idx| {
if idx == 0 {
ExportResponse::OpenResult
} else {
ExportResponse::OpenFolder
}
}),
ExportPhase::Error => match key.code {
KeyCode::Esc => ExportResponse::Cancelled,
KeyCode::Enter | KeyCode::Char(' ') => {
self.phase = ExportPhase::Options;
ExportResponse::Continue
}
_ => ExportResponse::Continue,
},
}
}
pub fn handle_click(&mut self, col: u16, row: u16) -> ExportResponse {
if rect_contains(self.esc_button_rect, col, row) {
return ExportResponse::Cancelled;
}
match self.phase {
ExportPhase::Options => self.handle_options_click(col, row),
ExportPhase::ConfirmOverwrite => self.handle_message_click(col, row, |idx| {
if idx == 0 {
ExportResponse::ProceedOverwrite
} else {
ExportResponse::Cancelled
}
}),
ExportPhase::Exporting => ExportResponse::Continue,
ExportPhase::Success => self.handle_message_click(col, row, |idx| {
if idx == 0 {
ExportResponse::OpenResult
} else {
ExportResponse::OpenFolder
}
}),
ExportPhase::Error => {
if rect_contains(self.msg_button_rects.first().copied(), col, row) {
self.phase = ExportPhase::Options;
}
ExportResponse::Continue
}
}
}
fn handle_options_click(&mut self, col: u16, row: u16) -> ExportResponse {
let clicked_format = self
.format_rects
.iter()
.find(|(_, r)| rect_contains(Some(*r), col, row))
.map(|(idx, _)| *idx);
if let Some(idx) = clicked_format {
self.focus = OptFocus::Format;
self.format_idx = idx;
return ExportResponse::Continue;
}
if rect_contains(self.title_rect, col, row) {
self.focus = OptFocus::Title;
return ExportResponse::Continue;
}
if rect_contains(self.images_rect, col, row) {
self.focus = OptFocus::Images;
self.apply_input(ControlInput::Activate);
return ExportResponse::Continue;
}
if rect_contains(self.diagrams_rect, col, row) {
self.focus = OptFocus::Diagrams;
self.apply_input(ControlInput::Activate);
return ExportResponse::Continue;
}
if rect_contains(self.stylesheet_rect, col, row) {
self.focus = OptFocus::Stylesheet;
self.apply_input(ControlInput::Activate);
return ExportResponse::Continue;
}
if rect_contains(self.export_button_rect, col, row) {
self.focus = OptFocus::Export;
return self.submit();
}
ExportResponse::Continue
}
fn handle_message_click(
&mut self,
col: u16,
row: u16,
activate: impl Fn(usize) -> ExportResponse,
) -> ExportResponse {
for (i, r) in self.msg_button_rects.iter().enumerate() {
if rect_contains(Some(*r), col, row) {
self.btn_focus = i;
return activate(i);
}
}
ExportResponse::Continue
}
fn handle_button_key(
&mut self,
key: &KeyEvent,
count: usize,
activate: impl Fn(usize) -> ExportResponse,
) -> ExportResponse {
match key.code {
KeyCode::Esc => ExportResponse::Cancelled,
KeyCode::Left | KeyCode::Right | KeyCode::Tab | KeyCode::BackTab if count > 1 => {
let delta = if matches!(key.code, KeyCode::Left | KeyCode::BackTab) {
count - 1
} else {
1
};
self.btn_focus = (self.btn_focus + delta) % count;
ExportResponse::Continue
}
KeyCode::Enter | KeyCode::Char(' ') => activate(self.btn_focus),
_ => ExportResponse::Continue,
}
}
fn handle_options_key(&mut self, key: &KeyEvent) -> ExportResponse {
if self.scroll_state.handle_paging_key(key) {
return ExportResponse::Continue;
}
match key.code {
KeyCode::Esc => ExportResponse::Cancelled,
KeyCode::Tab => {
self.focus = self.focus.next();
ExportResponse::Continue
}
KeyCode::BackTab => {
self.focus = self.focus.prev();
ExportResponse::Continue
}
KeyCode::Down => {
self.move_focus_down();
ExportResponse::Continue
}
KeyCode::Up => {
self.move_focus_up();
ExportResponse::Continue
}
KeyCode::Backspace if self.focus == OptFocus::Title => {
self.title.pop();
ExportResponse::Continue
}
KeyCode::Enter => {
if self.focus == OptFocus::Export {
self.submit()
} else {
self.focus = self.focus.next();
ExportResponse::Continue
}
}
KeyCode::Char(' ') if self.focus == OptFocus::Export => self.submit(),
KeyCode::Char(c) if self.focus == OptFocus::Title => {
self.push_title_char(c);
ExportResponse::Continue
}
_ => {
if let Some(input) = control_input_for(key.code) {
self.apply_input(input);
}
ExportResponse::Continue
}
}
}
fn move_focus_down(&mut self) {
if self.focus == OptFocus::Format && self.format_idx + 1 < self.formats.len() {
self.format_idx += 1;
} else {
self.focus = self.focus.next();
}
}
fn move_focus_up(&mut self) {
if self.focus == OptFocus::Format && self.format_idx > 0 {
self.format_idx -= 1;
} else {
self.focus = self.focus.prev();
}
}
fn apply_input(&mut self, input: ControlInput) {
match self.focus {
OptFocus::Format => {
if !matches!(input, ControlInput::Activate) {
self.format_idx =
cycle_index(self.format_idx, self.formats.len(), input_delta(input));
}
}
OptFocus::Images => {
if let ControlEvent::Changed(ControlValue::Toggle(v)) =
Control::Toggle.apply(ControlValue::Toggle(self.inline_images), input)
{
self.inline_images = v;
}
}
OptFocus::Diagrams => {
if let ControlEvent::Changed(ControlValue::Toggle(v)) =
Control::Toggle.apply(ControlValue::Toggle(self.render_diagrams), input)
{
self.render_diagrams = v;
}
}
OptFocus::Stylesheet => {
self.stylesheet_idx = cycle_index(
self.stylesheet_idx,
self.stylesheets.len(),
input_delta(input),
);
}
OptFocus::Title | OptFocus::Export => {}
}
}
fn push_title_char(&mut self, c: char) {
if !c.is_control() && self.title.chars().count() < TITLE_CHAR_CAP {
self.title.push(c);
}
}
fn submit(&mut self) -> ExportResponse {
let choices = self.choices();
self.submitted_title = choices.title.clone();
ExportResponse::Submit(choices)
}
fn choices(&self) -> ExportChoices {
let title = {
let t = self.title.trim();
if t.is_empty() {
None
} else {
Some(t.to_owned())
}
};
let stylesheet = self
.stylesheets
.get(self.stylesheet_idx)
.map(|(_, v)| v.clone())
.unwrap_or_else(|| "builtin".to_owned());
ExportChoices {
title,
inline_images: self.inline_images,
render_diagrams: self.render_diagrams,
stylesheet,
}
}
pub fn paste(&mut self, text: &str) {
if self.phase != ExportPhase::Options || self.focus != OptFocus::Title {
return;
}
let clean = sanitize_paste(text);
for c in clean.chars() {
if self.title.chars().count() >= TITLE_CHAR_CAP {
break;
}
self.title.push(c);
}
}
}
pub struct ExportView<'a> {
pub theme: &'a Theme,
pub cursor_visible: bool,
}
impl<'a> StatefulWidget for ExportView<'a> {
type State = ExportState;
fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
match state.phase {
ExportPhase::Options => self.render_options(area, buf, state),
ExportPhase::ConfirmOverwrite => {
let target = state
.target
.as_deref()
.map(display_name)
.unwrap_or_else(|| "the file".to_owned());
let lines = vec![
owned_line(format!("{target} already exists."), self.theme),
owned_line("Overwrite it?".to_owned(), self.theme),
];
self.render_message(
area,
buf,
state,
FRAME_TITLE,
ModalKind::Warning,
lines,
OVERWRITE_BUTTONS,
);
}
ExportPhase::Exporting => {
let lines = vec![owned_line("Exporting…".to_owned(), self.theme)];
self.render_message(area, buf, state, FRAME_TITLE, ModalKind::Normal, lines, &[]);
}
ExportPhase::Success => {
let path = state
.result_path
.as_ref()
.map(|p| p.display().to_string())
.unwrap_or_default();
let lines = vec![
owned_line("Exported to".to_owned(), self.theme),
owned_line(path, self.theme),
];
let buttons = [state.open_result_label(), OPEN_FOLDER_BUTTON.to_owned()];
let button_refs: Vec<&str> = buttons.iter().map(String::as_str).collect();
self.render_message(
area,
buf,
state,
"Export complete",
ModalKind::Normal,
lines,
&button_refs,
);
}
ExportPhase::Error => {
let msg = state.error_message.clone().unwrap_or_default();
let lines = vec![
owned_line("Export failed".to_owned(), self.theme),
owned_line(msg, self.theme),
];
self.render_message(
area,
buf,
state,
FRAME_TITLE,
ModalKind::Error,
lines,
ERROR_BUTTONS,
);
}
}
}
}
impl<'a> ExportView<'a> {
fn render_options(&self, area: Rect, buf: &mut Buffer, state: &mut ExportState) {
let labels: [&str; 4] = ["Title", "Inline images", "Inline diagrams", "Stylesheet"];
let label_w = labels.iter().map(|l| l.chars().count()).max().unwrap_or(0);
let style_labels: Vec<String> = state.stylesheets.iter().map(|(l, _)| l.clone()).collect();
let style_label_refs: Vec<&str> = style_labels.iter().map(String::as_str).collect();
let control_w = TITLE_FIELD_WIDTH
.max(toggle_width())
.max(pill_width(&style_label_refs));
let row_w = label_w + 2 + control_w;
let note_w = [
IMAGES_NOTE_ON,
IMAGES_NOTE_OFF,
DIAGRAMS_NOTE_ON,
DIAGRAMS_NOTE_OFF,
]
.iter()
.map(|n| NOTE_INDENT.len() + n.chars().count())
.max()
.unwrap_or(0);
let format_w = state
.formats
.iter()
.map(|f| LIST_INDENT.len() + MARKER_SELECTED.chars().count() + f.label.chars().count())
.max()
.unwrap_or(0);
let content_width = row_w.max(note_w).max(format_w) as u16;
state.clear_hit_rects();
let rows = form_rows(state.formats.len());
let content = ContentSize {
width: content_width.max(button_row_width(OPTION_BUTTONS)),
height: rows.len() as u16,
pinned_top: 0,
pinned_bottom: FOOTER_ROWS,
..Default::default()
};
let modal_area = centered_rect_for_content(content, area);
let inner_h = modal_area.height.saturating_sub(VERTICAL_CHROME_ROWS);
let list_height = inner_h.saturating_sub(FOOTER_ROWS);
state.scroll_state.observe(rows.len() as u16, list_height);
for row in focus_reveal_rows(&rows, state.focus, state.format_idx) {
state.scroll_state.ensure_visible(row);
}
let layout = draw_frame(
modal_area,
buf,
FrameOpts {
title: FRAME_TITLE,
kind: ModalKind::Normal,
show_close_hint: true,
content,
theme: self.theme,
},
);
state.esc_button_rect = layout.esc_hit_rect;
let inner = layout.body;
if inner.height == 0 || inner.width == 0 {
return;
}
let viewport = Rect {
x: inner.x,
y: inner.y,
width: inner.width,
height: list_height.min(inner.height),
};
let hit_w = ((label_w + 2 + control_w) as u16).min(inner.width);
let scroll = state.scroll_state.scroll as usize;
for (i, row) in rows
.iter()
.enumerate()
.skip(scroll)
.take(viewport.height as usize)
{
let row_area = Rect {
x: viewport.x,
y: viewport.y + (i - scroll) as u16,
width: viewport.width,
height: 1,
};
match *row {
FormRow::Spacer => {}
FormRow::Title => {
let focused = state.focus == OptFocus::Title;
let value_style = controls::text_value_style(focused, self.theme);
let mut control = vec![Span::styled(" ", value_style)];
control.extend(text_field_spans(
&state.title,
state.title.chars().count(),
focused && self.cursor_visible,
value_style,
self.theme.cursor,
));
self.render_row(buf, row_area, "Title", label_w, focused, control);
state.title_rect = Some(control_rect(row_area.x, row_area.y, hit_w));
}
FormRow::Images => {
let focused = state.focus == OptFocus::Images;
let control = toggle_spans(state.inline_images, focused, false, self.theme);
self.render_row(buf, row_area, "Inline images", label_w, focused, control);
state.images_rect = Some(control_rect(row_area.x, row_area.y, hit_w));
}
FormRow::ImagesNote => {
self.render_note(buf, row_area, images_note(state.inline_images));
}
FormRow::Diagrams => {
let focused = state.focus == OptFocus::Diagrams;
let control = toggle_spans(state.render_diagrams, focused, false, self.theme);
self.render_row(buf, row_area, "Inline diagrams", label_w, focused, control);
state.diagrams_rect = Some(control_rect(row_area.x, row_area.y, hit_w));
}
FormRow::DiagramsNote => {
self.render_note(buf, row_area, diagrams_note(state.render_diagrams));
}
FormRow::Stylesheet => {
let focused = state.focus == OptFocus::Stylesheet;
let control = pill_spans(
&style_label_refs,
state.stylesheet_idx,
focused,
false,
self.theme,
);
self.render_row(buf, row_area, "Stylesheet", label_w, focused, control);
state.stylesheet_rect = Some(control_rect(row_area.x, row_area.y, hit_w));
}
FormRow::FormatLabel => {
let style = controls::control_label_style(
state.focus == OptFocus::Format,
false,
self.theme,
);
Paragraph::new(Line::from(Span::styled("Format", style)))
.style(self.theme.modal_bg)
.render(row_area, buf);
}
FormRow::Format(idx) => {
let Some(label) = state.formats.get(idx).map(|f| f.label.clone()) else {
continue;
};
let selected = idx == state.format_idx;
let focused = selected && state.focus == OptFocus::Format;
self.render_format_row(buf, row_area, &label, selected, focused);
state.format_rects.push((idx, row_area));
}
}
}
if state.scroll_state.max_scroll() > 0 {
let bar_area = Rect {
x: layout.scrollbar_col,
y: viewport.y,
width: 1,
height: viewport.height,
};
crate::ui::scrollbar::render_for_scroll_state(
bar_area,
&state.scroll_state,
self.theme,
buf,
);
}
let button_y = viewport.y + viewport.height + 1;
if button_y < inner.y + inner.height {
let button_area = Rect {
x: inner.x,
y: button_y,
width: inner.width,
height: 1,
};
let focused_idx = match state.focus {
OptFocus::Export => 0,
_ => usize::MAX,
};
let rects =
render_button_row(button_area, buf, OPTION_BUTTONS, focused_idx, self.theme);
state.export_button_rect = rects.into_iter().next();
}
}
fn render_format_row(
&self,
buf: &mut Buffer,
area: Rect,
label: &str,
selected: bool,
focused: bool,
) {
let marker = if selected {
MARKER_SELECTED
} else {
MARKER_UNSELECTED
};
let spans = if focused {
let text = format!("{LIST_INDENT}{marker}{label}");
let pad = (area.width as usize).saturating_sub(text.chars().count());
vec![Span::styled(
format!("{text}{}", " ".repeat(pad)),
controls::focused_style(self.theme),
)]
} else if selected {
vec![
Span::styled(LIST_INDENT, self.theme.modal_item),
Span::styled(marker, self.theme.modal_item_selected_unfocused),
Span::styled(label.to_owned(), self.theme.modal_item),
]
} else {
vec![Span::styled(
format!("{LIST_INDENT}{marker}{label}"),
self.theme.modal_item,
)]
};
Paragraph::new(Line::from(spans))
.style(self.theme.modal_bg)
.render(area, buf);
}
fn render_row(
&self,
buf: &mut Buffer,
area: Rect,
label: &str,
label_w: usize,
focused: bool,
control: Vec<Span<'static>>,
) {
let spans = control_row_spans(label, label_w + 2, control, focused, false, self.theme);
Paragraph::new(Line::from(spans))
.style(self.theme.modal_bg)
.render(area, buf);
}
fn render_note(&self, buf: &mut Buffer, area: Rect, text: &str) {
Paragraph::new(Line::from(Span::styled(
format!("{NOTE_INDENT}{text}"),
self.theme.modal_description,
)))
.style(self.theme.modal_bg)
.render(area, buf);
}
#[allow(clippy::too_many_arguments)]
fn render_message(
&self,
area: Rect,
buf: &mut Buffer,
state: &mut ExportState,
title: &str,
kind: ModalKind,
lines: Vec<Line<'static>>,
buttons: &[&str],
) {
state.clear_hit_rects();
let has_buttons = !buttons.is_empty();
let natural_line_w = lines.iter().map(Line::width).max().unwrap_or(0) as u16;
let buttons_w = if has_buttons {
button_row_width(buttons)
} else {
0
};
let content_w = natural_line_w
.min(PROSE_CONTENT_WIDTH.max(buttons_w))
.max(buttons_w);
let prospective_modal_w = content_w.saturating_add(2 * MAX_PAD_H).min(area.width);
let prospective_pad_h = compute_pad_h(prospective_modal_w, content_w, MAX_PAD_H);
let body_render_w = prospective_modal_w
.saturating_sub(2 * prospective_pad_h)
.max(1);
let body_rows = wrapped_rows(&lines, body_render_w);
let footer_rows = if has_buttons {
footer_row_count(buttons, content_w, area.width, MAX_PAD_H)
} else {
0
};
let body_h = body_rows + if has_buttons { 1 + footer_rows } else { 0 };
let content = ContentSize {
width: content_w,
height: 0,
pinned_top: body_h,
pinned_bottom: 0,
..Default::default()
};
let modal_area = centered_rect_for_content(content, area);
let layout = draw_frame(
modal_area,
buf,
FrameOpts {
title,
kind,
show_close_hint: true,
content,
theme: self.theme,
},
);
state.esc_button_rect = layout.esc_hit_rect;
let inner = layout.body;
if inner.height == 0 || inner.width == 0 {
return;
}
let mut y = inner.y;
let bottom = inner.y + inner.height;
for line in lines {
if y >= bottom {
return;
}
let rows = wrapped_rows(std::slice::from_ref(&line), inner.width);
let height = rows.min(bottom.saturating_sub(y));
let row = Rect {
x: inner.x,
y,
width: inner.width,
height,
};
Paragraph::new(line)
.alignment(Alignment::Center)
.wrap(Wrap { trim: false })
.style(self.theme.modal_bg)
.render(row, buf);
y = y.saturating_add(height);
}
if has_buttons {
y = y.saturating_add(1); if y >= bottom {
return;
}
let button_area = Rect {
x: inner.x,
y,
width: inner.width,
height: bottom.saturating_sub(y),
};
state.msg_button_rects =
render_button_row(button_area, buf, buttons, state.btn_focus, self.theme);
} else {
state.msg_button_rects.clear();
}
}
}
fn owned_line(text: String, theme: &Theme) -> Line<'static> {
Line::from(Span::styled(text, theme.modal_item))
}
fn images_note(on: bool) -> &'static str {
if on {
IMAGES_NOTE_ON
} else {
IMAGES_NOTE_OFF
}
}
fn diagrams_note(on: bool) -> &'static str {
if on {
DIAGRAMS_NOTE_ON
} else {
DIAGRAMS_NOTE_OFF
}
}
fn control_rect(x: u16, y: u16, width: u16) -> Rect {
Rect {
x,
y,
width,
height: 1,
}
}
fn rect_contains(rect: Option<Rect>, col: u16, row: u16) -> bool {
match rect {
Some(r) => col >= r.x && col < r.x + r.width && row >= r.y && row < r.y + r.height,
None => false,
}
}
fn display_name(path: &Path) -> String {
path.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| path.display().to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::{backend::TestBackend, Terminal};
fn theme() -> &'static Theme {
Box::leak(Box::new(Theme::default()))
}
fn key(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
fn state() -> ExportState {
ExportState::new(
vec![
ExportFormat::html(),
ExportFormat::custom("PDF (weasyprint)"),
],
"My Doc".to_owned(),
false,
true,
vec![
("Default".to_owned(), "builtin".to_owned()),
("paper.css".to_owned(), "/cfg/export/paper.css".to_owned()),
],
0,
)
}
#[test]
fn tab_cycles_through_all_focus_targets() {
let mut s = state();
assert_eq!(s.focus, OptFocus::Title, "focus starts on the first row");
for expected in [
OptFocus::Images,
OptFocus::Diagrams,
OptFocus::Stylesheet,
OptFocus::Format,
OptFocus::Export,
OptFocus::Title,
] {
s.handle_key(&key(KeyCode::Tab));
assert_eq!(s.focus, expected);
}
}
#[test]
fn arrows_move_within_the_format_list_then_spill() {
let mut s = state(); s.focus = OptFocus::Format;
assert_eq!(s.format_idx, 0);
s.handle_key(&key(KeyCode::Down)); assert_eq!(s.focus, OptFocus::Format);
assert_eq!(s.format_idx, 1);
s.handle_key(&key(KeyCode::Down)); assert_eq!(s.focus, OptFocus::Export);
assert_eq!(s.format_idx, 1, "selection is unchanged when spilling");
s.handle_key(&key(KeyCode::Up)); assert_eq!(s.focus, OptFocus::Format);
s.handle_key(&key(KeyCode::Up)); assert_eq!(s.format_idx, 0);
s.handle_key(&key(KeyCode::Up)); assert_eq!(s.focus, OptFocus::Stylesheet);
}
#[test]
fn left_right_cycle_the_format_selection() {
let mut s = state();
s.focus = OptFocus::Format;
s.handle_key(&key(KeyCode::Right));
assert_eq!(s.format_idx, 1);
s.handle_key(&key(KeyCode::Right)); assert_eq!(s.format_idx, 0);
s.handle_key(&key(KeyCode::Left)); assert_eq!(s.format_idx, 1);
}
#[test]
fn typing_appends_to_title_only_when_focused() {
let mut s = state();
s.focus = OptFocus::Title;
s.title.clear();
s.handle_key(&key(KeyCode::Char('H')));
s.handle_key(&key(KeyCode::Char('i')));
assert_eq!(s.title, "Hi");
s.handle_key(&key(KeyCode::Tab));
s.handle_key(&key(KeyCode::Char('x')));
assert_eq!(s.title, "Hi");
}
#[test]
fn arrows_set_toggle_off_and_on() {
let mut s = state();
s.focus = OptFocus::Images;
s.handle_key(&key(KeyCode::Right));
assert!(s.inline_images);
s.handle_key(&key(KeyCode::Left));
assert!(!s.inline_images);
s.handle_key(&key(KeyCode::Char(' ')));
assert!(s.inline_images);
}
#[test]
fn stylesheet_pill_cycles_and_wraps() {
let mut s = state();
s.focus = OptFocus::Stylesheet;
assert_eq!(s.stylesheet_idx, 0);
s.handle_key(&key(KeyCode::Right));
assert_eq!(s.stylesheet_idx, 1);
s.handle_key(&key(KeyCode::Right));
assert_eq!(s.stylesheet_idx, 0, "wraps back to Default");
s.handle_key(&key(KeyCode::Left));
assert_eq!(s.stylesheet_idx, 1, "wraps backwards");
}
#[test]
fn enter_submits_only_from_the_export_button() {
let mut s = state();
s.inline_images = true;
s.focus = OptFocus::Stylesheet;
s.handle_key(&key(KeyCode::Right));
assert_eq!(s.handle_key(&key(KeyCode::Enter)), ExportResponse::Continue);
assert_eq!(s.focus, OptFocus::Format);
assert_eq!(s.handle_key(&key(KeyCode::Enter)), ExportResponse::Continue);
assert_eq!(s.focus, OptFocus::Export);
let resp = s.handle_key(&key(KeyCode::Enter));
assert_eq!(
resp,
ExportResponse::Submit(ExportChoices {
title: Some("My Doc".to_owned()),
inline_images: true,
render_diagrams: true,
stylesheet: "/cfg/export/paper.css".to_owned(),
})
);
assert_eq!(s.submitted_title, Some("My Doc".to_owned()));
}
#[test]
fn blank_title_submits_as_none() {
let mut s = state();
s.title = " ".to_owned();
s.focus = OptFocus::Export;
let resp = s.handle_key(&key(KeyCode::Enter));
match resp {
ExportResponse::Submit(c) => assert_eq!(c.title, None),
other => panic!("expected Submit, got {other:?}"),
}
}
#[test]
fn escape_cancels_in_every_phase() {
for setup in [
ExportPhase::Options,
ExportPhase::ConfirmOverwrite,
ExportPhase::Exporting,
ExportPhase::Success,
ExportPhase::Error,
] {
let mut s = state();
s.phase = setup;
assert_eq!(
s.handle_key(&key(KeyCode::Esc)),
ExportResponse::Cancelled,
"phase {setup:?}"
);
}
}
#[test]
fn overwrite_phase_confirms_and_cancels() {
let mut s = state();
s.enter_confirm_overwrite(PathBuf::from("/docs/guide.html"));
assert_eq!(s.phase, ExportPhase::ConfirmOverwrite);
assert_eq!(
s.handle_key(&key(KeyCode::Enter)),
ExportResponse::ProceedOverwrite
);
s.handle_key(&key(KeyCode::Right));
assert_eq!(
s.handle_key(&key(KeyCode::Enter)),
ExportResponse::Cancelled
);
}
#[test]
fn success_phase_buttons_open_browser_and_folder() {
let mut s = state();
s.set_success(PathBuf::from("/docs/guide.html"));
assert_eq!(s.phase, ExportPhase::Success);
assert_eq!(
s.handle_key(&key(KeyCode::Enter)),
ExportResponse::OpenResult
);
s.handle_key(&key(KeyCode::Right));
assert_eq!(
s.handle_key(&key(KeyCode::Enter)),
ExportResponse::OpenFolder
);
}
#[test]
fn error_back_returns_to_options() {
let mut s = state();
s.set_error("boom".to_owned());
assert_eq!(s.phase, ExportPhase::Error);
let resp = s.handle_key(&key(KeyCode::Enter));
assert_eq!(resp, ExportResponse::Continue);
assert_eq!(s.phase, ExportPhase::Options);
}
#[test]
fn paste_only_lands_in_focused_title() {
let mut s = state();
s.focus = OptFocus::Title;
s.title.clear();
s.paste("a\nb\tc");
assert_eq!(s.title, "abc", "control chars flattened away");
s.focus = OptFocus::Images;
s.paste("nope");
assert_eq!(s.title, "abc", "paste ignored off the title field");
}
#[test]
fn ctrl_chords_do_not_pollute_title() {
let mut s = state();
s.title.clear();
let ctrl_s = KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL);
s.handle_key(&ctrl_s);
assert_eq!(s.title, "");
}
#[test]
fn renders_options_form() {
let backend = TestBackend::new(70, 22);
let mut terminal = Terminal::new(backend).unwrap();
let mut s = state(); terminal
.draw(|frame| {
let view = ExportView {
theme: theme(),
cursor_visible: true,
};
frame.render_stateful_widget(view, frame.area(), &mut s);
})
.unwrap();
let content: String = terminal
.backend()
.buffer()
.content
.iter()
.map(|c| c.symbol().chars().next().unwrap_or(' '))
.collect();
assert!(content.contains("Export"), "frame title: {content}");
assert!(content.contains("Format"), "format list: {content}");
assert!(content.contains("HTML"), "HTML format row: {content}");
assert!(content.contains("Inline images"), "images row: {content}");
assert!(content.contains("Stylesheet"), "stylesheet row: {content}");
assert!(
content.contains(IMAGES_NOTE_OFF),
"images-off note: {content}"
);
assert!(
content.contains(DIAGRAMS_NOTE_ON),
"diagrams-on note: {content}"
);
}
#[test]
fn toggle_note_swaps_with_state() {
assert_eq!(images_note(true), IMAGES_NOTE_ON);
assert_eq!(images_note(false), IMAGES_NOTE_OFF);
assert_eq!(diagrams_note(true), DIAGRAMS_NOTE_ON);
assert_eq!(diagrams_note(false), DIAGRAMS_NOTE_OFF);
}
fn render_modal(s: &mut ExportState, w: u16, h: u16) {
let backend = TestBackend::new(w, h);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|frame| {
let view = ExportView {
theme: theme(),
cursor_visible: true,
};
frame.render_stateful_widget(view, frame.area(), s);
})
.unwrap();
}
#[test]
fn click_flips_a_toggle_and_focuses_its_row() {
let mut s = state(); render_modal(&mut s, 70, 22);
let r = s.images_rect.expect("images rect captured at render");
let resp = s.handle_click(r.x, r.y);
assert_eq!(resp, ExportResponse::Continue);
assert!(s.inline_images, "clicking the toggle flips it on");
assert_eq!(s.focus, OptFocus::Images);
}
#[test]
fn click_cycles_the_stylesheet_pill() {
let mut s = state(); render_modal(&mut s, 70, 22);
let r = s
.stylesheet_rect
.expect("stylesheet rect captured at render");
s.handle_click(r.x, r.y);
assert_eq!(
s.stylesheet_idx, 1,
"click advances the pill like Right/Space"
);
}
#[test]
fn click_on_export_button_submits() {
let mut s = state();
render_modal(&mut s, 70, 22);
let r = s
.export_button_rect
.expect("export button rect captured at render");
let resp = s.handle_click(r.x, r.y);
assert!(matches!(resp, ExportResponse::Submit(_)));
assert_eq!(s.focus, OptFocus::Export);
}
#[test]
fn click_on_esc_hint_cancels() {
let mut s = state();
render_modal(&mut s, 70, 22);
let r = s.esc_button_rect.expect("esc hint rect captured at render");
assert_eq!(s.handle_click(r.x, r.y), ExportResponse::Cancelled);
}
#[test]
fn click_on_success_buttons_opens_browser_and_folder() {
let mut s = state();
s.set_success(PathBuf::from("/docs/guide.html"));
render_modal(&mut s, 70, 14);
let browser = s.msg_button_rects[0];
assert_eq!(
s.handle_click(browser.x, browser.y),
ExportResponse::OpenResult
);
let folder = s.msg_button_rects[1];
assert_eq!(
s.handle_click(folder.x, folder.y),
ExportResponse::OpenFolder
);
}
#[test]
fn a_long_error_message_wraps_instead_of_truncating() {
let (w, h) = (70u16, 24u16);
let backend = TestBackend::new(w, h);
let mut terminal = Terminal::new(backend).unwrap();
let mut s = state();
s.set_error(
"export command exited with status 1: WARNING: Expected a media type, \
got '(prefers-color-scheme: dark)' which is not valid; the offending \
rule was ignored and the document rendered without it ERRORTAIL"
.to_owned(),
);
terminal
.draw(|frame| {
let view = ExportView {
theme: theme(),
cursor_visible: true,
};
frame.render_stateful_widget(view, frame.area(), &mut s);
})
.unwrap();
let buf = terminal.backend().buffer();
let screen: String = (0..h)
.map(|y| {
(0..w)
.map(|x| buf.content[(y * w + x) as usize].symbol())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n");
assert!(screen.contains("Export failed"), "head missing:\n{screen}");
assert!(
screen.contains("ERRORTAIL"),
"the tail of a long error must be visible, not truncated:\n{screen}"
);
}
#[test]
fn renders_success_phase() {
let backend = TestBackend::new(70, 14);
let mut terminal = Terminal::new(backend).unwrap();
let mut s = state();
s.set_success(PathBuf::from("/docs/guide.html"));
terminal
.draw(|frame| {
let view = ExportView {
theme: theme(),
cursor_visible: true,
};
frame.render_stateful_widget(view, frame.area(), &mut s);
})
.unwrap();
let content: String = terminal
.backend()
.buffer()
.content
.iter()
.map(|c| c.symbol().chars().next().unwrap_or(' '))
.collect();
assert!(content.contains("Export complete"), "{content}");
assert!(content.contains("Open in browser"), "{content}");
assert!(content.contains("Open folder"), "{content}");
}
#[test]
fn the_format_list_shows_every_format_and_shares_the_form() {
let mut s = ExportState::new(
vec![
ExportFormat::html(),
ExportFormat::custom("PDF (weasyprint)"),
],
"My Doc".to_owned(),
false,
true,
vec![("Default".to_owned(), "builtin".to_owned())],
0,
);
let backend = TestBackend::new(70, 22);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|frame| {
let view = ExportView {
theme: theme(),
cursor_visible: true,
};
frame.render_stateful_widget(view, frame.area(), &mut s);
})
.unwrap();
let content: String = terminal
.backend()
.buffer()
.content
.iter()
.map(|c| c.symbol().chars().next().unwrap_or(' '))
.collect();
assert!(content.contains("Export"), "frame title: {content}");
assert!(content.contains("Format"), "format label: {content}");
assert!(content.contains("HTML"), "HTML row: {content}");
assert!(
content.contains("PDF (weasyprint)"),
"custom row: {content}"
);
for expected in ["Title", "Inline images", "Inline diagrams", "Stylesheet"] {
assert!(content.contains(expected), "{expected} missing: {content}");
}
}
fn state_with_selected(formats: Vec<ExportFormat>, idx: usize) -> ExportState {
let mut s = ExportState::new(formats, String::new(), false, true, vec![], 0);
s.format_idx = idx;
s
}
#[test]
fn the_success_button_names_the_selected_format() {
let mut s = state_with_selected(vec![ExportFormat::html(), ExportFormat::custom("PDF")], 1);
s.set_success(PathBuf::from("/docs/guide.pdf"));
let backend = TestBackend::new(70, 14);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|frame| {
let view = ExportView {
theme: theme(),
cursor_visible: true,
};
frame.render_stateful_widget(view, frame.area(), &mut s);
})
.unwrap();
let content: String = terminal
.backend()
.buffer()
.content
.iter()
.map(|c| c.symbol().chars().next().unwrap_or(' '))
.collect();
assert!(content.contains("Open file"), "{content}");
assert!(!content.contains("Open in browser"), "{content}");
assert!(content.contains("Open folder"), "{content}");
}
#[test]
fn custom_success_buttons_still_resolve_to_open_and_folder() {
let mut s = state_with_selected(vec![ExportFormat::html(), ExportFormat::custom("PDF")], 1);
s.set_success(PathBuf::from("/docs/guide.pdf"));
render_modal(&mut s, 70, 14);
let open = s.msg_button_rects[0];
let folder = s.msg_button_rects[1];
assert_eq!(
s.handle_click(open.x, open.y),
ExportResponse::OpenResult,
"primary button"
);
assert_eq!(
s.handle_click(folder.x, folder.y),
ExportResponse::OpenFolder,
"folder button"
);
}
#[test]
fn the_form_rows_end_with_the_format_list() {
let rows = form_rows(3);
assert_eq!(rows[0], FormRow::Title);
assert_eq!(
&rows[rows.len() - 4..],
&[
FormRow::FormatLabel,
FormRow::Format(0),
FormRow::Format(1),
FormRow::Format(2)
]
);
let list_start = rows
.iter()
.position(|r| *r == FormRow::FormatLabel)
.unwrap();
for control in [
FormRow::Title,
FormRow::Images,
FormRow::Diagrams,
FormRow::Stylesheet,
] {
assert!(rows.iter().position(|r| *r == control).unwrap() < list_start);
}
}
#[test]
fn focus_reveal_puts_the_control_last() {
let rows = form_rows(2);
let reveal = focus_reveal_rows(&rows, OptFocus::Images, 0);
let images = rows.iter().position(|r| *r == FormRow::Images).unwrap() as u16;
let note = rows.iter().position(|r| *r == FormRow::ImagesNote).unwrap() as u16;
assert_eq!(reveal, vec![note, images], "note first, control last");
assert!(focus_reveal_rows(&rows, OptFocus::Export, 0).is_empty());
assert_eq!(
focus_reveal_rows(&rows, OptFocus::Format, 1),
vec![rows.iter().position(|r| *r == FormRow::Format(1)).unwrap() as u16]
);
}
fn state_with_formats(n: usize) -> ExportState {
let formats: Vec<ExportFormat> = std::iter::once(ExportFormat::html())
.chain((1..n).map(|i| ExportFormat::custom(&format!("Converter {i}"))))
.collect();
ExportState::new(
formats,
"My Doc".to_owned(),
false,
true,
vec![("Default".to_owned(), "builtin".to_owned())],
0,
)
}
#[test]
fn a_long_format_list_keeps_the_export_button_on_screen() {
let mut s = state_with_formats(12);
s.focus = OptFocus::Format;
s.format_idx = 11;
render_modal(&mut s, 70, 16);
let rect = s
.export_button_rect
.expect("the button is pinned, not scrolled");
assert!(rect.y < 16, "painted inside the terminal: {rect:?}");
assert!(
s.scroll_state.max_scroll() > 0,
"the body should be overflowing for this to mean anything"
);
assert!(
s.format_rects.iter().any(|(idx, _)| *idx == 11),
"the focused format should be painted: {:?}",
s.format_rects
);
}
#[test]
fn a_row_scrolled_out_of_view_leaves_no_click_rect() {
let mut s = state_with_formats(12);
render_modal(&mut s, 70, 40);
assert!(s.title_rect.is_some());
assert!(s.images_rect.is_some());
assert_eq!(s.scroll_state.max_scroll(), 0, "nothing to scroll");
s.focus = OptFocus::Format;
s.format_idx = 11;
render_modal(&mut s, 70, 16);
assert!(s.scroll_state.scroll > 0);
assert!(s.title_rect.is_none(), "scrolled-away title keeps no rect");
assert!(s.images_rect.is_none());
assert!(s.stylesheet_rect.is_none());
}
#[test]
fn a_click_on_a_scrolled_list_selects_the_row_under_the_pointer() {
let mut s = state_with_formats(12);
s.focus = OptFocus::Format;
s.format_idx = 11;
render_modal(&mut s, 70, 16);
let (idx, rect) = *s
.format_rects
.first()
.expect("some format rows are painted");
assert!(idx > 0, "the list is scrolled past HTML: {idx}");
s.handle_click(rect.x + 3, rect.y);
assert_eq!(s.format_idx, idx, "clicked row wins");
assert_eq!(s.focus, OptFocus::Format);
}
#[test]
fn the_wheel_scrolls_the_options_body_within_bounds() {
let mut s = state_with_formats(12);
render_modal(&mut s, 70, 16);
assert_eq!(s.scroll_state.scroll, 0, "focus starts on the first row");
s.handle_wheel(3);
assert_eq!(s.scroll_state.scroll, 3);
s.handle_wheel(1000);
assert_eq!(
s.scroll_state.scroll,
s.scroll_state.max_scroll(),
"clamped"
);
s.handle_wheel(-1000);
assert_eq!(s.scroll_state.scroll, 0, "clamped at the top");
s.set_error("boom".to_owned());
s.handle_wheel(5);
assert_eq!(s.scroll_state.scroll, 0);
}
#[test]
fn paging_keys_scroll_but_arrows_still_move_focus() {
let mut s = state_with_formats(12);
render_modal(&mut s, 70, 16);
s.handle_key(&key(KeyCode::PageDown));
assert!(s.scroll_state.scroll > 0, "PgDn pages the body");
s.handle_key(&key(KeyCode::Home));
assert_eq!(s.scroll_state.scroll, 0);
assert_eq!(s.focus, OptFocus::Title);
s.handle_key(&key(KeyCode::Down));
assert_eq!(s.focus, OptFocus::Images);
assert_eq!(s.scroll_state.scroll, 0);
}
#[test]
fn focus_moving_back_up_scrolls_the_controls_into_view() {
let mut s = state_with_formats(12);
s.focus = OptFocus::Format;
s.format_idx = 11;
render_modal(&mut s, 70, 16);
assert!(s.title_rect.is_none(), "scrolled past the title");
s.focus = OptFocus::Title;
render_modal(&mut s, 70, 16);
assert_eq!(s.scroll_state.scroll, 0);
assert!(s.title_rect.is_some(), "the title is back on screen");
}
}