use super::super::render::{confirm_delete_buttons, confirm_delete_prompt};
#[allow(clippy::wildcard_imports)]
use super::super::*;
use std::fs;
use tempfile::TempDir;
pub(super) fn test_config() -> Config {
let mut config = Config::default();
config.layout.move_on_change = false;
config
}
pub(super) fn make_app() -> (TempDir, App) {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().join("issues");
fs::create_dir_all(&dir).unwrap();
let config_path = tmp.path().join("tix.toml");
let config = Config {
issues_dir: dir.clone(),
..test_config()
};
config.save(&config_path).unwrap();
let config = Config::load_with_warning(&config_path, |_| {}).unwrap();
let app = App::new(&config).unwrap();
(tmp, app)
}
pub(super) fn write_issue(
dir: &std::path::Path,
name: &str,
number: u32,
title: &str,
status: &str,
commit: Option<&str>,
) {
let commit_line = match commit {
Some(h) => format!("commit: \"{h}\""),
None => String::new(),
};
let contents = format!(
"---\nnumber: {number}\ntitle: \"{title}\"\nstatus: \"{status}\"\nlabels: []\npriority: \"medium\"\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n{commit_line}\n---\n\nBody text."
);
fs::write(dir.join(name), contents).unwrap();
}
pub(super) fn perf_fixture(n: u32) -> (TempDir, Config) {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().join("issues");
fs::create_dir_all(&dir).unwrap();
let config_path = tmp.path().join("tix.toml");
let config = Config {
issues_dir: dir.clone(),
..test_config()
};
config.save(&config_path).unwrap();
for i in 1..=n {
let status = if i % 10 == 0 { "todo" } else { "done" };
write_issue(
&dir,
&format!("perf-{i:05}.md"),
i,
&format!("Card {i:05}"),
status,
None,
);
}
let config = Config::load_with_warning(&config_path, |_| {}).unwrap();
(tmp, config)
}
pub(super) fn press(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, crossterm::event::KeyModifiers::NONE)
}
pub(super) fn press_with_modifiers(
code: KeyCode,
modifiers: crossterm::event::KeyModifiers,
) -> KeyEvent {
KeyEvent::new(code, modifiers)
}
pub(super) fn make_app_with_planning() -> (TempDir, App) {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().join("issues");
fs::create_dir_all(&dir).unwrap();
let mut config = Config {
issues_dir: dir.clone(),
..test_config()
};
config.cycles.insert(
"Cycle 1".into(),
crate::config::PlanningCycle {
description: Some("kickoff".into()),
start_date: Some("2026-10-01".into()),
due_date: Some("2999-12-31".into()),
},
);
config.cycles.insert(
"Cycle 2".into(),
crate::config::PlanningCycle {
description: Some("next".into()),
start_date: Some("2999-01-01".into()),
due_date: Some("2999-01-15".into()),
},
);
let issue = "---\nnumber: 1\ntitle: \"A\"\nstatus: \"todo\"\nlabels: []\npriority: \"medium\"\ncycles: [\"Cycle 1\"]\nmilestones: [\"GA\"]\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n---\n\nBody text.";
fs::create_dir_all(dir.join("alice")).unwrap();
fs::write(dir.join("alice/2026-01-01-a.md"), issue).unwrap();
let milestones_dir = tmp.path().join("docs/milestones");
fs::create_dir_all(&milestones_dir).unwrap();
fs::write(
milestones_dir.join("2026-01-01-ga.md"),
"---\nname: \"GA\"\ntarget_date: \"2026-12-31\"\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n---\n\nship it\n",
)
.unwrap();
let config_path = tmp.path().join("tix.toml");
config.save(&config_path).unwrap();
let config = Config::load_with_warning(&config_path, |_| {}).unwrap();
let app = App::new(&config).unwrap();
(tmp, app)
}
pub(super) fn make_app_with_rollover_dialog() -> (TempDir, App) {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().join("issues");
fs::create_dir_all(&dir).unwrap();
let mut config = Config {
issues_dir: dir.clone(),
..test_config()
};
config.cycles.clear();
config.cycles.insert(
"Cycle 1".into(),
crate::config::PlanningCycle {
description: Some("expired".into()),
start_date: Some("2000-01-01".into()),
due_date: Some("2000-01-02".into()),
},
);
config.cycles.insert(
"Cycle 2".into(),
crate::config::PlanningCycle {
description: Some("next".into()),
start_date: Some("2999-01-01".into()),
due_date: Some("2999-01-15".into()),
},
);
let issue = "---\nnumber: 1\ntitle: \"A\"\nstatus: \"todo\"\nlabels: []\npriority: \"medium\"\ncycles: [\"Cycle 1\"]\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n---\n\nBody text.";
fs::create_dir_all(dir.join("alice")).unwrap();
fs::write(dir.join("alice/2026-01-01-a.md"), issue).unwrap();
let config_path = tmp.path().join("tix.toml");
config.save(&config_path).unwrap();
let config = Config::load_with_warning(&config_path, |_| {}).unwrap();
let app = App::new(&config).unwrap();
(tmp, app)
}
pub(super) fn filter_modal_focus(app: &App) -> crate::search::FilterField {
app.filter_modal
.as_ref()
.expect("filter modal open")
.focus()
}
pub(super) fn write_issue_with_custom_key(dir: &std::path::Path, name: &str, key: &str) {
write_issue(dir, name, 1, "A", "todo", None);
let path = dir.join(name);
let text = std::fs::read_to_string(&path).unwrap();
let text = text.replace("labels: []\n", &format!("labels: []\n{key}: \"ci-4471\"\n"));
std::fs::write(&path, text).unwrap();
}
pub(super) fn repair_mode(message: &str, key: Option<&str>) -> Mode {
Mode::EditRepair {
filename: "2026-01-01-a.md".to_string(),
message: message.to_string(),
key: key.map(str::to_string),
problems: Vec::new(),
temp: None,
written: true,
discard_armed: false,
}
}
pub(super) fn form_focus(app: &App) -> crate::ui::form::FormFocus {
app.form.as_ref().expect("form open").focus
}
pub(super) fn drawn_date_picker_day(app: &App, target: &str) -> (u16, u16) {
use ratatui::backend::TestBackend;
let backend = TestBackend::new(app.frame_size.get().width, app.frame_size.get().height);
let mut terminal = ratatui::Terminal::new(backend).unwrap();
terminal
.draw(|f| crate::app::render(app, f, f.area()))
.unwrap();
let picker = app.date_picker.as_ref().expect("picker open");
let rect = picker.popup_rect(app.frame_size.get());
let days_y = rect.y + 3;
let weeks = rect.height.saturating_sub(6);
for row in 0..weeks {
for col in 0..7u16 {
let x = rect.x + 1 + col * 3 + 1;
let y = days_y + row;
if picker
.date_at(app.frame_size.get(), x, y)
.map(crate::ui::date_picker::format_date)
.as_deref()
== Some(target)
{
return (x, y);
}
}
}
panic!("no day cell found for {target}");
}
pub(super) fn make_app_with_cards(n: u32) -> (TempDir, App) {
let (tmp, mut app) = make_app();
let dir = app.store.config.issues_dir.clone();
for i in 1..=n {
write_issue(
&dir,
&format!("card-{i:02}.md"),
i,
&format!("Card {i:02}"),
"todo",
None,
);
}
app.refresh_corpus().unwrap();
(tmp, app)
}
pub(super) fn make_mouse_app() -> (TempDir, App) {
let (tmp, mut app) = make_app();
app.mouse.double_click_window = Duration::from_secs(60);
let dir = app.store.config.issues_dir.clone();
write_issue(&dir, "a.md", 1, "Alpha", "todo", None);
write_issue(&dir, "b.md", 2, "Beta", "todo", None);
write_issue(&dir, "c.md", 3, "Gamma", "in progress", None);
write_issue(&dir, "d.md", 4, "Delta", "in review", None);
write_issue(&dir, "e.md", 5, "Epsilon", "done", Some("abc123"));
app.refresh_corpus().unwrap();
app.frame_size.set(Rect::new(0, 0, 80, 40));
(tmp, app)
}
pub(super) fn make_sparse_order_mouse_app() -> (TempDir, App) {
let (tmp, mut app) = make_app();
app.mouse.double_click_window = Duration::from_secs(60);
let dir = app.store.config.issues_dir.clone();
write_issue(&dir, "a.md", 1, "Alpha", "todo", None);
write_issue(&dir, "b.md", 2, "Beta", "todo", None);
write_issue(&dir, "c.md", 3, "Gamma", "todo", None);
write_issue(&dir, "d.md", 4, "Delta", "todo", None);
app.refresh_corpus().unwrap();
app.frame_size.set(Rect::new(0, 0, 80, 40));
(tmp, app)
}
pub(super) fn make_blocked_mouse_app() -> (TempDir, App) {
let (tmp, mut app) = make_mouse_app();
app.store
.link("1", "2", crate::relationships::RelationKind::Blocks)
.unwrap();
app.refresh_corpus().unwrap();
(tmp, app)
}
pub(super) fn done_col(app: &App) -> usize {
let name = app
.store
.config
.default_config()
.effective_done_column()
.expect("the fixture board has a done column");
(0..app.board.total_cols())
.find(|c| app.board.column_name(*c) == name)
.unwrap_or_else(|| panic!("no column named {name}"))
}
pub(super) fn down_left(x: u16, y: u16) -> MouseEvent {
MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: x,
row: y,
modifiers: crossterm::event::KeyModifiers::NONE,
}
}
pub(super) fn down_right(x: u16, y: u16) -> MouseEvent {
MouseEvent {
kind: MouseEventKind::Down(MouseButton::Right),
column: x,
row: y,
modifiers: crossterm::event::KeyModifiers::NONE,
}
}
pub(super) fn up_left(x: u16, y: u16) -> MouseEvent {
MouseEvent {
kind: MouseEventKind::Up(MouseButton::Left),
column: x,
row: y,
modifiers: crossterm::event::KeyModifiers::NONE,
}
}
pub(super) fn drag_event(x: u16, y: u16) -> MouseEvent {
MouseEvent {
kind: MouseEventKind::Drag(MouseButton::Left),
column: x,
row: y,
modifiers: crossterm::event::KeyModifiers::NONE,
}
}
pub(super) fn card_cell(app: &App, col: usize, row: usize) -> (u16, u16) {
let board_area = app.board_mouse_area(app.frame_size.get()).unwrap();
let rect = board::compute_column_rects(board_area, app.board.total_cols())[col];
let mut y = rect.y + 1;
for before in 0..row {
y += crate::ui::widgets::card_line_count(
&app.board.columns[col].cards[before],
rect.width as usize,
true,
3,
);
}
(rect.x + 2, y)
}
pub(super) fn column_cell(app: &App, col: usize) -> (u16, u16) {
let board_area = app.board_mouse_area(app.frame_size.get()).unwrap();
let rect = board::compute_column_rects(board_area, app.board.total_cols())[col];
(rect.x + 2, rect.y + rect.height - 2)
}
pub(super) fn moved_to(x: u16, y: u16) -> MouseEvent {
MouseEvent {
kind: MouseEventKind::Moved,
column: x,
row: y,
modifiers: crossterm::event::KeyModifiers::NONE,
}
}
pub(super) fn scroll_up(x: u16, y: u16) -> MouseEvent {
MouseEvent {
kind: MouseEventKind::ScrollUp,
column: x,
row: y,
modifiers: crossterm::event::KeyModifiers::NONE,
}
}
pub(super) fn scroll_down(x: u16, y: u16) -> MouseEvent {
MouseEvent {
kind: MouseEventKind::ScrollDown,
column: x,
row: y,
modifiers: crossterm::event::KeyModifiers::NONE,
}
}
pub(super) fn draw_help(app: &App, w: u16, h: u16) -> String {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let mut terminal = Terminal::new(TestBackend::new(w, h)).unwrap();
terminal.draw(|f| render(app, f, f.area())).unwrap();
let buf = terminal.backend().buffer();
let mut text = String::new();
for y in 0..buf.area.height {
for x in 0..buf.area.width {
text.push_str(buf[(x, y)].symbol());
}
text.push('\n');
}
text
}
pub(super) fn open_detail(app: &mut App) {
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert!(matches!(app.mode, Mode::ActionMenu { .. }));
assert!(select_menu_action(app, &IssueAction::OpenDetail));
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert!(matches!(app.mode, Mode::Detail));
assert!(app.detail.is_some());
}
pub(super) fn draw_board(app: &App, w: u16, h: u16) -> Vec<String> {
render_to_cells(app, w, h).unwrap()
}
pub(super) fn flush_line(cells: &[String], width: u16, row: u16) -> String {
let start = (row as usize) * (width as usize);
cells[start..start + width as usize].concat()
}
pub(super) fn make_multi_board_app() -> (TempDir, App) {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().join("issues");
fs::create_dir_all(&dir).unwrap();
let config_path = tmp.path().join("tix.toml");
let mut config = Config {
issues_dir: dir.clone(),
..test_config()
};
config.kanban.configurations.insert(
"release".into(),
crate::config::BoardConfiguration::default(),
);
config.save(&config_path).unwrap();
let config = Config::load_with_warning(&config_path, |_| {}).unwrap();
let app = App::new(&config).unwrap();
app.frame_size.set(Rect::new(0, 0, 80, 24));
(tmp, app)
}
pub(super) fn drawn_confirm_buttons(app: &App) -> ((u16, u16), (u16, u16)) {
let backend =
ratatui::backend::TestBackend::new(app.frame_size.get().width, app.frame_size.get().height);
let mut terminal = ratatui::Terminal::new(backend).unwrap();
terminal
.draw(|f| crate::app::render(app, f, f.area()))
.unwrap();
let prompt = confirm_delete_prompt(app.frame_size.get());
let (yes, no) = confirm_delete_buttons(prompt);
((yes.x + 2, yes.y), (no.x + 1, no.y))
}
pub(super) fn focus_filter_field(app: &mut App, field: FilterField) {
app.filter_modal
.as_mut()
.expect("filter modal open")
.focus_field(field);
}
pub(super) fn drawn_detail_row(app: &App, row: usize) -> (u16, u16) {
let backend =
ratatui::backend::TestBackend::new(app.frame_size.get().width, app.frame_size.get().height);
let mut terminal = ratatui::Terminal::new(backend).unwrap();
terminal
.draw(|f| crate::app::render(app, f, f.area()))
.unwrap();
let content = app.frame_size.get();
(content.x + 2, content.y + 1 + row as u16)
}
pub(super) fn open_dashboard_detail(app: &mut App) {
let mut modal = crate::ui::settings::SettingsModal::new();
modal.level = ConfigLevel::Detail;
app.settings = Some(modal);
app.mode = Mode::Settings;
}
pub(super) fn menu_actions(app: &App) -> Vec<IssueAction> {
app.action_menu
.as_ref()
.expect("menu open")
.items()
.iter()
.map(|item| item.action.clone())
.collect()
}
pub(super) fn select_menu_action(app: &mut App, wanted: &IssueAction) -> bool {
let len = app.action_menu.as_ref().map_or(0, ActionMenu::visible_len);
for _ in 0..=len {
let _ = app.handle_key(press(KeyCode::Up)).unwrap();
}
for _ in 0..=len {
let selected = app
.action_menu
.as_ref()
.and_then(ActionMenu::selected_item)
.map(|item| item.action.clone());
if selected.as_ref() == Some(wanted) {
return true;
}
let _ = app.handle_key(press(KeyCode::Down)).unwrap();
}
false
}
pub(super) fn make_terminal_blocked_app() -> (TempDir, App) {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().join("issues");
fs::create_dir_all(&dir).unwrap();
let mut config = Config {
issues_dir: dir.clone(),
..test_config()
};
{
let board = config
.kanban
.configurations
.get_mut("default")
.expect("default board");
board.columns = vec!["todo".into(), "wontfix".into(), "done".into()];
board.done_column = Some("done".into());
board.category_map = [
("wontfix".to_string(), "canceled".to_string()),
("done".to_string(), "completed".to_string()),
]
.into_iter()
.collect();
}
let config_path = tmp.path().join("tix.toml");
config.save(&config_path).unwrap();
let config = Config::load_with_warning(&config_path, |_| {}).unwrap();
write_issue(&dir, "1.md", 1, "Blocker", "todo", None);
write_issue(&dir, "2.md", 2, "Blocked", "wontfix", None);
let mut app = App::new(&config).unwrap();
app.store
.link("1", "2", crate::relationships::RelationKind::Blocks)
.unwrap();
app.refresh_corpus().unwrap();
app.frame_size.set(Rect::new(0, 0, 80, 40));
let col = (0..app.board.real_cols())
.find(|col| app.board.column_name(*col) == "wontfix")
.expect("the fixture board has the wontfix column");
app.board.select_column(col);
app.board.selected = 0;
(tmp, app)
}
pub(super) fn make_shell_app() -> (TempDir, App) {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().join("issues");
fs::create_dir_all(&dir).unwrap();
let mut config = Config::starter();
config.layout.move_on_change = false;
config.issues_dir = dir;
let app = App::new(&config).unwrap();
(tmp, app)
}
pub(super) fn make_branchy_app() -> (TempDir, App) {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().join("issues");
fs::create_dir_all(&dir).unwrap();
let mut config = Config {
issues_dir: dir.clone(),
..test_config()
};
config.kanban.configurations.insert(
"release".into(),
crate::config::BoardConfiguration::default(),
);
config.templates.insert(
"bug".into(),
crate::config::IssueTemplate {
body: Some("## Steps\n".into()),
labels: vec!["bug".into()],
..crate::config::IssueTemplate::default()
},
);
config.templates.insert(
"feature".into(),
crate::config::IssueTemplate {
body: Some("## Motivation\n".into()),
labels: vec!["enhancement".into()],
..crate::config::IssueTemplate::default()
},
);
let path = tmp.path().join("tix.toml");
config.save(&path).unwrap();
let config = Config::load_with_warning(&path, |_| {}).unwrap();
let app = App::new(&config).unwrap();
(tmp, app)
}
pub(super) fn make_cycle_app() -> (TempDir, App) {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().join("issues");
fs::create_dir_all(dir.join("alice")).unwrap();
let mut config = Config {
issues_dir: dir.clone(),
..test_config()
};
let cycle = |description: &str, start: &str, due: Option<&str>| crate::config::PlanningCycle {
description: Some(description.to_string()),
start_date: Some(start.to_string()),
due_date: due.map(str::to_string),
};
config.cycles.insert(
"Historical".into(),
cycle("old", "2000-01-01", Some("2000-02-01")),
);
config
.cycles
.insert("Ongoing".into(), cycle("open", "2026-01-01", None));
config.cycles.insert(
"Later".into(),
cycle("future", "2999-01-01", Some("2999-02-01")),
);
for (name, number, status, cycle) in [
("2026-01-01-done.md", 1, "done", "Ongoing"),
("2026-01-02-open.md", 2, "todo", "Ongoing"),
("2026-01-03-old.md", 3, "todo", "Historical"),
] {
fs::write(
dir.join("alice").join(name),
format!(
"---\nnumber: {number}\ntitle: \"Issue {number}\"\nstatus: \"{status}\"\n\
labels: []\npriority: \"medium\"\ncycles: [\"{cycle}\"]\n\
created: 2026-01-01\nupdated: 2026-01-01\n---\n\nBody."
),
)
.unwrap();
}
let config_path = tmp.path().join("tix.toml");
config.save(&config_path).unwrap();
let config = Config::load_with_warning(&config_path, |_| {}).unwrap();
let mut app = App::new(&config).unwrap();
app.rollover_dialog = None;
app.mode = Mode::Board;
(tmp, app)
}
pub(super) fn make_templated_app() -> (TempDir, App) {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().join("issues");
fs::create_dir_all(&dir).unwrap();
let mut config = Config::starter();
config.layout.move_on_change = false;
config.issues_dir = dir;
config.templates.insert(
"bug".into(),
crate::config::IssueTemplate {
body: Some("## Steps to reproduce\n\n## Expected\n".into()),
labels: vec!["bug".into()],
..crate::config::IssueTemplate::default()
},
);
config.templates.insert(
"feature".into(),
crate::config::IssueTemplate {
body: Some("## Motivation\n".into()),
labels: vec!["enhancement".into()],
..crate::config::IssueTemplate::default()
},
);
let path = tmp.path().join("tix.toml");
config.save(&path).unwrap();
let config = Config::load_with_warning(&path, |_| {}).unwrap();
assert!(
config.templates.contains_key("bug"),
"templates must survive the tix.toml round trip"
);
let app = App::new(&config).unwrap();
(tmp, app)
}
pub(super) fn type_text(app: &mut App, text: &str) {
for c in text.chars() {
app.handle_key(press(KeyCode::Char(c))).unwrap();
}
}
pub(super) fn make_app_with_config_stores() -> (TempDir, App) {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
let dir = root.join("issues");
fs::create_dir_all(&dir).unwrap();
crate::cycle_store::CycleStore::new(root)
.save(
"Sprint 7",
&crate::config::PlanningCycle {
description: Some("kickoff".into()),
start_date: Some("2026-10-01".into()),
due_date: Some("2999-12-31".into()),
},
None,
)
.unwrap();
let issue = "---\nnumber: 1\ntitle: \"A\"\nstatus: \"todo\"\nlabels: []\npriority: \"medium\"\ncycles: [\"Sprint 7\"]\nmilestones: [\"GA\"]\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n---\n\nBody text.";
fs::create_dir_all(dir.join("alice")).unwrap();
fs::write(dir.join("alice/2026-01-01-a.md"), issue).unwrap();
let milestones_dir = root.join("docs/milestones");
fs::create_dir_all(&milestones_dir).unwrap();
fs::write(
milestones_dir.join("2026-01-01-ga.md"),
"---\nname: \"GA\"\ntarget_date: \"2026-12-31\"\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n---\n\nship it\n",
)
.unwrap();
fs::create_dir_all(root.join("docs/boards")).unwrap();
let mut config = Config {
issues_dir: dir.clone(),
..test_config()
};
config
.kanban
.configurations
.insert("default".into(), crate::config::starter_board());
let config_path = root.join("tix.toml");
config.save(&config_path).unwrap();
let config = Config::load_with_warning(&config_path, |_| {}).unwrap();
let app = App::new(&config).unwrap();
(tmp, app)
}
pub(super) fn open_config(app: &mut App) {
let _ = app.handle_key(press(KeyCode::F(12))).unwrap();
assert!(
matches!(app.mode, Mode::Settings),
"F12 opens the Config mode"
);
}
pub(super) fn draw_buffer(app: &App, w: u16, h: u16) -> ratatui::buffer::Buffer {
let backend = ratatui::backend::TestBackend::new(w, h);
let mut terminal = ratatui::Terminal::new(backend).unwrap();
terminal
.draw(|f| crate::app::render(app, f, f.area()))
.unwrap();
terminal.backend().buffer().clone()
}
pub(super) fn write_planning_issue(
dir: &std::path::Path,
name: &str,
number: u32,
title: &str,
status: &str,
cycles: &[&str],
milestones: &[&str],
) {
let list = |values: &[&str]| {
values
.iter()
.map(|v| format!("\"{v}\""))
.collect::<Vec<_>>()
.join(", ")
};
let cycles_line = if cycles.is_empty() {
String::new()
} else {
format!("cycles: [{}]\n", list(cycles))
};
let milestones_line = if milestones.is_empty() {
String::new()
} else {
format!("milestones: [{}]\n", list(milestones))
};
fs::write(
dir.join(name),
format!(
"---\nnumber: {number}\ntitle: \"{title}\"\nstatus: \"{status}\"\nlabels: []\n\
priority: \"medium\"\n{cycles_line}{milestones_line}\
created: \"2026-01-01\"\nupdated: \"2026-01-01\"\n---\n\nBody."
),
)
.unwrap();
}
pub(super) fn open_issue_regions(app: &App) -> Vec<String> {
let mut names: Vec<String> = app
.frame_hit_regions
.borrow()
.iter()
.filter_map(|region| match ®ion.action {
Action::OpenIssue(filename) => Some(filename.clone()),
_ => None,
})
.collect();
names.sort();
names
}
pub(super) fn app_with_mention(body: &str) -> (TempDir, App, u32) {
let (tmp, mut app) = make_app();
let number = seed_issue(&mut app, body);
app.mention_candidates = vec!["alice".to_string()];
recount_mentions(&mut app);
(tmp, app, number)
}
pub(super) fn seed_issue(app: &mut App, body: &str) -> u32 {
let mut issue: Issue = serde_yaml::from_str(
"number: 0\ntitle: \"Fix login\"\nstatus: \"todo\"\nlabels: []\npriority: \"medium\"\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n",
)
.expect("a minimal issue");
app.store.create(&mut issue, body, None).expect("create");
issue.number
}
pub(super) fn recount_mentions(app: &mut App) {
app.unread_mentions = app
.mention_store()
.map_or(0, |store| store.unread_count(&app.mention_candidates));
}
pub(super) fn frame_text(app: &App, width: u16, height: u16) -> String {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
terminal.draw(|f| render(app, f, f.area())).unwrap();
let buf = terminal.backend().buffer();
let mut out = String::new();
for y in 0..buf.area.height {
for x in 0..buf.area.width {
out.push_str(buf[(x, y)].symbol());
}
out.push('\n');
}
out
}