pub mod bookmarks;
pub mod dialog;
pub mod git;
pub mod help;
pub mod icons;
pub mod info;
pub mod preview;
pub mod status;
pub mod tabbar;
pub mod tree;
use ratatui::layout::{Constraint, Layout};
use ratatui::style::Style;
use ratatui::Frame;
use crate::app::{App, Mode, StatusbarLayout};
pub fn render(frame: &mut Frame, app: &mut App) {
if let Some(bg) = app.cfg.ui.theme.bg() {
let area = frame.area();
frame.buffer_mut().set_style(area, Style::new().bg(bg));
}
let show_tabs = match app.cfg.ui.tabbar.as_str() {
"always" => true,
"hidden" => false,
_ => app.tab_count() > 1,
};
let layout = StatusbarLayout::parse(&app.cfg.ui.statusbar);
let split = layout == StatusbarLayout::Split;
let top_present = split || show_tabs;
let mut constraints = Vec::with_capacity(3);
if top_present {
constraints.push(Constraint::Length(1));
}
constraints.push(Constraint::Min(0));
constraints.push(Constraint::Length(1));
let areas = Layout::vertical(constraints).split(frame.area());
let mut idx = 0;
let top = if top_present {
let a = areas[idx];
idx += 1;
Some(a)
} else {
None
};
let content = areas[idx];
idx += 1;
let bottom = areas[idx];
if let Some(top) = top {
if show_tabs {
tabbar::render(frame, app, top);
}
if split {
status::render_context(frame, app, top);
}
}
if split {
status::render_footer(frame, app, bottom);
} else {
status::render_combined(frame, app, bottom);
}
if app.is_git_detail() {
git::render_detail(frame, app, content);
} else if app.is_git_graph_picker() {
git::render_graph_picker(frame, app, content);
} else if app.is_git_graph() {
git::render_graph(frame, app, content);
} else if app.is_git_log() {
git::render_log(frame, app, content);
} else if app.is_git_branches() {
git::render_branches(frame, app, content);
} else if app.is_git_view() {
git::render_changes(frame, app, content);
} else {
match app.mode {
Mode::Tree => tree::render(frame, app, content),
Mode::Preview => preview::render(frame, app, content),
}
}
if app.show_help {
help::render(frame, app, frame.area());
}
if app.is_bookmark_list() {
bookmarks::render(frame, app, frame.area());
}
if app.is_info() {
info::render(frame, app, frame.area());
}
if app.is_dialog() {
dialog::render(frame, app, frame.area());
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app::App;
use crate::config::Config;
use ratatui::backend::TestBackend;
use ratatui::style::Color;
use ratatui::Terminal;
#[cfg(feature = "git")]
#[test]
fn git_graph_renders_lanes_refs_and_detail() {
use std::process::Command;
let dir = std::env::temp_dir().join("konoma_graph_render_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let git = |args: &[&str]| {
Command::new("git")
.current_dir(&dir)
.args(args)
.output()
.unwrap();
};
git(&["init", "-q"]);
git(&["config", "user.email", "t@t.t"]);
git(&["config", "user.name", "t"]);
std::fs::write(dir.join("a.txt"), b"a").unwrap();
git(&["add", "."]);
git(&["commit", "-q", "-m", "A init"]);
git(&["checkout", "-q", "-b", "feature"]);
std::fs::write(dir.join("c.txt"), b"c").unwrap();
git(&["add", "."]);
git(&["commit", "-q", "-m", "C feature work"]);
git(&["checkout", "-q", "-"]);
std::fs::write(dir.join("b.txt"), b"b").unwrap();
git(&["add", "."]);
git(&["commit", "-q", "-m", "B main work"]);
let mut app = App::new(dir.canonicalize().unwrap(), Config::default()).unwrap();
app.open_git_graph();
assert!(app.is_git_graph());
let mut term = Terminal::new(TestBackend::new(72, 12)).unwrap();
term.draw(|f| render(f, &mut app)).unwrap();
let buf = term.backend().buffer();
let text: String = buf.content().iter().map(|c| c.symbol()).collect();
assert!(
text.contains("C feature work"),
"feature の subject: {text}"
);
assert!(text.contains("B main work"));
assert!(text.contains("feature"), "ref ラベル feature");
assert!(text.contains('●'), "グラフのノード ●");
assert!(
text.contains('├') || text.contains('┘') || text.contains('┐'),
"角ばった連結文字が出ない: {text}"
);
let has_color = buf
.content()
.iter()
.any(|c| !matches!(c.fg, Color::Reset | Color::White | Color::Black));
assert!(has_color, "グラフレーンに色が付いていない");
app.git_graph_move(1);
app.open_git_graph_detail();
assert!(app.is_git_detail(), "Enter でコミット詳細が開く");
assert!(!app.git_detail_lines().is_empty(), "詳細に diff 行がある");
app.close_git_detail();
std::fs::write(dir.join("a.txt"), b"a changed").unwrap(); std::fs::write(dir.join("untracked.txt"), b"new").unwrap(); git(&["add", "b.txt"]); std::fs::write(dir.join("b.txt"), b"b staged change").unwrap();
git(&["add", "b.txt"]);
app.open_git_graph();
let row0 = app.git_graph_selected_row().expect("先頭行");
assert!(row0.worktree, "先頭が作業ツリー行");
assert_eq!(app.git_graph_sel(), 0, "カーソルが作業ツリー行に乗る");
let mut term2 = Terminal::new(TestBackend::new(88, 14)).unwrap();
term2.draw(|f| render(f, &mut app)).unwrap();
let buf2 = term2.backend().buffer();
let text2: String = buf2.content().iter().map(|c| c.symbol()).collect();
assert!(
text2.contains("Uncommitted changes"),
"作業ツリー行の見出し: {text2}"
);
assert!(text2.contains('●'), "作業ツリーノード ●: {text2}");
app.open_git_graph_detail();
assert!(app.is_git_detail(), "Enter で作業ツリー詳細が開く");
let headers = app
.git_detail_lines()
.iter()
.filter(|l| {
matches!(l.kind, crate::git::DiffLineKind::Context)
&& l.old_no.is_none()
&& l.new_no.is_none()
})
.count();
assert!(
headers >= 2,
"作業ツリー diff が複数ファイル: headers={headers}"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn preview_line_start_end_horizontal_jump() {
let dir = std::env::temp_dir().join("konoma_hscroll_jump_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let long = format!("START{}END", "x".repeat(200));
std::fs::write(dir.join("a.txt"), &long).unwrap();
let mut cfg = Config::default();
cfg.ui.wrap = false; let mut app = App::new(dir.clone(), cfg).unwrap();
app.rebuild_tree().unwrap();
app.tree_descend().unwrap(); assert!(
matches!(app.mode, crate::app::Mode::Preview),
"Preview 遷移"
);
let dump = |app: &mut App| -> String {
let mut term = Terminal::new(TestBackend::new(40, 8)).unwrap();
term.draw(|f| render(f, app)).unwrap();
let buf = term.backend().buffer();
buf.content().iter().map(|c| c.symbol()).collect()
};
let s0 = dump(&mut app);
assert!(s0.contains("START"), "行頭に START が無い");
assert!(!s0.contains("END"), "行頭で END が見えてはいけない");
app.preview_hscroll_end();
let se = dump(&mut app);
assert!(se.contains("END"), "$ で行末(END)が見えない");
assert!(!se.contains("START"), "行末で START が見えてはいけない");
app.preview_hscroll_home();
let sh = dump(&mut app);
assert!(
sh.contains("START") && !sh.contains("END"),
"0 で行頭へ戻らない"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn global_bg_fills_buffer() {
let dir = std::env::temp_dir().join("konoma_ui_bg_test");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("a.txt"), b"hi").unwrap();
let mut cfg = Config::default();
cfg.ui.theme.bg = "#102030".into();
let mut app = App::new(dir.clone(), cfg).unwrap();
let mut term = Terminal::new(TestBackend::new(24, 8)).unwrap();
term.draw(|f| render(f, &mut app)).unwrap();
let buf = term.backend().buffer();
let target = Color::Rgb(16, 32, 48);
let total = buf.content().len();
let hit = buf.content().iter().filter(|c| c.bg == target).count();
assert!(
hit * 5 >= total * 4,
"全体背景が塗られていない: {hit}/{total} セルのみ"
);
std::fs::remove_dir_all(&dir).ok();
}
fn row_text(app: &mut App, w: u16, h: u16, row: u16) -> String {
let mut term = Terminal::new(TestBackend::new(w, h)).unwrap();
term.draw(|f| render(f, app)).unwrap();
let buf = term.backend().buffer();
(0..w)
.map(|x| buf.cell((x, row)).unwrap().symbol().to_string())
.collect()
}
#[test]
fn statusbar_split_puts_context_top_hints_bottom() {
let dir = std::env::temp_dir().join("konoma_chrome_split_test");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("a.txt"), b"x").unwrap();
let mut app = App::new(dir.clone(), Config::default()).unwrap(); let (w, h) = (70, 6);
assert!(row_text(&mut app, w, h, 0).contains("TREE"), "上にモード");
assert!(row_text(&mut app, w, h, 0).contains("path:"), "上にパス");
let bottom = row_text(&mut app, w, h, h - 1);
assert!(bottom.contains("jk:move"), "下にヒント: {bottom}");
assert!(!bottom.contains("TREE"), "下にモードは出さない: {bottom}");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn statusbar_bottom_puts_everything_on_bottom() {
let dir = std::env::temp_dir().join("konoma_chrome_bottom_test");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("a.txt"), b"x").unwrap();
let mut cfg = Config::default();
cfg.ui.statusbar = "bottom".into();
let mut app = App::new(dir.clone(), cfg).unwrap();
let (w, h) = (70, 6);
let bottom = row_text(&mut app, w, h, h - 1);
assert!(bottom.contains("TREE"), "下にモード: {bottom}");
assert!(bottom.contains("jk:move"), "下にヒント: {bottom}");
assert!(!row_text(&mut app, w, h, 0).contains("TREE"));
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn lang_jp_localizes_chrome_and_help() {
let dir = std::env::temp_dir().join("konoma_lang_jp_test");
std::fs::create_dir_all(&dir).unwrap();
let mut cfg = Config::default();
cfg.ui.lang = "jp".into();
cfg.ui.statusbar = "bottom".into(); let mut app = App::new(dir.clone(), cfg).unwrap();
let buf = |app: &mut App| -> String {
let mut term = Terminal::new(TestBackend::new(72, 26)).unwrap();
term.draw(|f| render(f, app)).unwrap();
term.backend()
.buffer()
.content()
.iter()
.map(|c| c.symbol())
.collect::<String>()
};
let s = buf(&mut app);
assert!(s.contains('ツ'), "日本語チップが無い");
assert!(s.contains('移'), "日本語ヒントが無い");
assert!(
!s.contains("TREE") && !s.contains("jk:move"),
"英語が残る: {s}"
);
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(feature = "git")]
#[test]
fn tree_shows_git_status_marker() {
let dir = std::env::temp_dir().join("konoma_git_marker_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
git2::Repository::init(&dir).unwrap();
std::fs::write(dir.join("foo.txt"), b"x").unwrap(); let mut app = App::new(dir.canonicalize().unwrap(), Config::default()).unwrap();
let mut term = Terminal::new(TestBackend::new(40, 8)).unwrap();
term.draw(|f| render(f, &mut app)).unwrap();
let buf = term.backend().buffer();
let found = buf
.content()
.iter()
.any(|c| c.symbol() == "U" && c.fg == ratatui::style::Color::LightGreen);
assert!(found, "未追跡 'U' マーカーが無い");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn tree_visible_range_keeps_selection_onscreen() {
let dir = std::env::temp_dir().join("konoma_tree_visrange_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("aaa_first.txt"), b"x").unwrap();
for i in 0..30 {
std::fs::write(dir.join(format!("m{i:02}.txt")), b"x").unwrap();
}
std::fs::write(dir.join("zzz_last.txt"), b"x").unwrap();
let mut app = App::new(dir.canonicalize().unwrap(), Config::default()).unwrap();
app.selected = app.entries.len() - 1;
assert!(
app.entries[app.selected]
.path
.file_name()
.and_then(|n| n.to_str())
.unwrap()
.starts_with("zzz_last"),
"末尾が zzz_last でない(ソート前提が崩れた)"
);
let mut term = Terminal::new(TestBackend::new(40, 8)).unwrap(); term.draw(|f| render(f, &mut app)).unwrap();
let s: String = term
.backend()
.buffer()
.content()
.iter()
.map(|c| c.symbol())
.collect();
assert!(
s.contains("zzz_last"),
"選択した末尾行が画面に出ない: 可視範囲が選択を外した"
);
assert!(
!s.contains("aaa_first"),
"先頭行は画面外のはずなのに描画された(スクロールが効いていない)"
);
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(feature = "git")]
#[test]
fn tree_title_shows_branch() {
let dir = std::env::temp_dir().join("konoma_branch_title_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
git2::Repository::init(&dir).unwrap();
let mut app = App::new(dir.canonicalize().unwrap(), Config::default()).unwrap();
app.refresh_git_if_needed();
assert!(app.git_branch().is_some(), "ブランチ名が取得できない");
let mut term = Terminal::new(TestBackend::new(50, 6)).unwrap();
term.draw(|f| render(f, &mut app)).unwrap();
let s: String = term
.backend()
.buffer()
.content()
.iter()
.map(|c| c.symbol())
.collect();
assert!(s.contains('⎇'), "タイトルにブランチ記号が無い");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn help_overlay_renders_when_shown() {
let dir = std::env::temp_dir().join("konoma_help_render_test");
std::fs::create_dir_all(&dir).unwrap();
let mut app = App::new(dir.clone(), Config::default()).unwrap();
let buf_text = |app: &mut App| -> String {
let mut term = Terminal::new(TestBackend::new(72, 50)).unwrap();
term.draw(|f| render(f, app)).unwrap();
term.backend()
.buffer()
.content()
.iter()
.map(|c| c.symbol())
.collect::<String>()
};
assert!(!buf_text(&mut app).contains("Tabs"));
app.show_help = true;
let s = buf_text(&mut app);
assert!(
s.contains("Tabs") && s.contains("Copy"),
"ヘルプのセクションが無い"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn sort_indicator_and_menu_render() {
let dir = std::env::temp_dir().join("konoma_sort_render_test");
std::fs::create_dir_all(&dir).unwrap();
let mut app = App::new(dir.clone(), Config::default()).unwrap();
let buf_text = |app: &mut App| -> String {
let mut term = Terminal::new(TestBackend::new(80, 12)).unwrap();
term.draw(|f| render(f, app)).unwrap();
term.backend()
.buffer()
.content()
.iter()
.map(|c| c.symbol())
.collect::<String>()
};
assert!(
buf_text(&mut app).contains("sort:"),
"context に並び表示が無い"
);
app.open_sort_menu();
let s = buf_text(&mut app);
assert!(
s.contains("[n]ame") || s.contains("名前"),
"ソートメニューが出ない"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn bookmark_overlay_renders() {
let dir = std::env::temp_dir().join("konoma_bm_render_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("sub")).unwrap();
let base = std::env::temp_dir().join("konoma_bm_render_base");
let _ = std::fs::remove_dir_all(&base);
let mut app = App::new(dir.clone(), Config::default()).unwrap();
app.bookmarks = crate::bookmarks::Bookmarks::with_base(base.clone(), &app.open_dir);
app.bookmarks.set('a', dir.join("sub")).unwrap();
let buf_text = |app: &mut App| -> String {
let mut term = Terminal::new(TestBackend::new(80, 16)).unwrap();
term.draw(|f| render(f, app)).unwrap();
term.backend()
.buffer()
.content()
.iter()
.map(|c| c.symbol())
.collect::<String>()
};
assert!(!buf_text(&mut app).contains("Bookmarks"));
app.open_bookmark_list();
let s = buf_text(&mut app);
assert!(s.contains("Bookmarks"), "一覧オーバーレイのタイトルが無い");
assert!(
s.contains("Local") || s.contains("ローカル"),
"ローカル見出しが無い"
);
std::fs::remove_dir_all(&dir).ok();
std::fs::remove_dir_all(&base).ok();
}
#[test]
fn dialog_overlay_renders_input_and_confirm() {
let dir = std::env::temp_dir().join("konoma_dialog_render_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("a.txt"), b"x").unwrap();
let mut app = App::new(dir.clone(), Config::default()).unwrap();
let buf_text = |app: &mut App| -> String {
let mut term = Terminal::new(TestBackend::new(72, 24)).unwrap();
term.draw(|f| render(f, app)).unwrap();
term.backend()
.buffer()
.content()
.iter()
.map(|c| c.symbol())
.collect::<String>()
};
app.start_create();
app.dialog_input_push('h');
app.dialog_input_push('i');
assert!(buf_text(&mut app).contains("> hi"), "入力テキストが出ない");
app.dialog_cancel();
assert!(!buf_text(&mut app).contains("> hi"), "取消で消える");
app.selected = 0;
app.start_delete();
let s = buf_text(&mut app);
assert!(s.contains("Trash"), "ゴミ箱の選択肢が出ない: {s:?}");
assert!(
s.contains("Delete permanently"),
"完全削除の選択肢が出ない: {s:?}"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn batch_rename_preview_overlay_renders() {
let dir = std::env::temp_dir().join("konoma_batchrename_render_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("a.txt"), b"x").unwrap();
std::fs::write(dir.join("b.txt"), b"x").unwrap();
let mut app = App::new(dir.clone(), Config::default()).unwrap();
app.rebuild_tree().unwrap();
app.visual_select_scope(true);
app.start_batch_rename();
for c in "img_{n}".chars() {
app.dialog_input_push(c);
}
app.dialog_submit().unwrap();
assert!(app.dialog_is_preview());
let mut term = Terminal::new(TestBackend::new(72, 24)).unwrap();
term.draw(|f| render(f, &mut app)).unwrap();
let s: String = term
.backend()
.buffer()
.content()
.iter()
.map(|c| c.symbol())
.collect();
assert!(s.contains('→'), "旧→新 の矢印が出ない");
assert!(s.contains("img_1"), "新名のプレビューが出ない");
assert!(s.contains("y = apply"), "適用ヒントが出ない: {s:?}");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn selection_marker_and_count_render() {
let dir = std::env::temp_dir().join("konoma_selmarker_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("a.txt"), b"x").unwrap();
let mut app = App::new(dir.clone(), Config::default()).unwrap();
app.rebuild_tree().unwrap();
let buf_text = |app: &mut App| -> String {
let mut term = Terminal::new(TestBackend::new(72, 24)).unwrap();
term.draw(|f| render(f, app)).unwrap();
term.backend()
.buffer()
.content()
.iter()
.map(|c| c.symbol())
.collect::<String>()
};
assert!(!buf_text(&mut app).contains('●'));
app.selected = 0;
app.toggle_select();
let s = buf_text(&mut app);
assert!(s.contains('●'), "選択マーカーが出ない");
assert!(
s.contains("sel") || s.contains('選'),
"選択件数が出ない: {s:?}"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn info_popup_renders_file_details() {
let dir = std::env::temp_dir().join("konoma_info_render_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("hello.txt"), b"hello world").unwrap(); let mut app = App::new(dir.clone(), Config::default()).unwrap();
app.rebuild_tree().unwrap();
app.selected = app
.entries
.iter()
.position(|e| e.path.ends_with("hello.txt"))
.unwrap();
let text = |app: &mut App| -> String {
let mut term = Terminal::new(TestBackend::new(70, 16)).unwrap();
term.draw(|f| render(f, app)).unwrap();
term.backend()
.buffer()
.content()
.iter()
.map(|c| c.symbol())
.collect()
};
assert!(!text(&mut app).contains("Info"));
app.toggle_info();
assert!(app.is_info());
let s = text(&mut app);
assert!(s.contains("Info"), "タイトル");
assert!(s.contains("file"), "種別");
assert!(s.contains("11 B"), "サイズ");
assert!(s.contains("rw-r--r--"), "権限");
assert!(s.contains("UTC"), "更新日時");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn details_columns_render_aligned() {
let dir = std::env::temp_dir().join("konoma_details_render_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("assets")).unwrap();
std::fs::write(dir.join("main.rs"), vec![0u8; 12600]).unwrap(); std::fs::write(dir.join("README.md"), vec![0u8; 1200]).unwrap();
let mut cfg = Config::default();
cfg.ui.details = vec!["size".into(), "modified".into()];
let mut app = App::new(dir.clone(), cfg).unwrap();
app.rebuild_tree().unwrap();
let rows = |app: &mut App| -> Vec<String> {
let mut term = Terminal::new(TestBackend::new(60, 10)).unwrap();
term.draw(|f| render(f, app)).unwrap();
let buf = term.backend().buffer();
(0..10u16)
.map(|y| {
(0..60u16)
.map(|x| buf.cell((x, y)).unwrap().symbol().to_string())
.collect::<String>()
})
.collect()
};
let r = rows(&mut app);
let joined = r.join("\n");
assert!(joined.contains("12.3 KB"), "サイズ列: {joined}");
assert!(joined.contains("--"), "ディレクトリのサイズは --");
assert!(!joined.contains("UTC"), "列は短い日時(UTC ラベル無し)");
let main_row = r.iter().find(|l| l.contains("main.rs")).unwrap();
let readme_row = r.iter().find(|l| l.contains("README.md")).unwrap();
let col_main = main_row.find("12.3 KB").unwrap() + "12.3 KB".len();
let col_readme = readme_row.find("1.2 KB").unwrap() + "1.2 KB".len();
assert_eq!(col_main, col_readme, "サイズ列が右揃いで縦に揃う");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn mode_chips_have_background_colors() {
let dir = std::env::temp_dir().join("konoma_modechip_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("a.txt"), b"x").unwrap();
let mut app = App::new(dir.clone(), Config::default()).unwrap();
let chip_bg = |app: &mut App, color: Color| -> bool {
let mut term = Terminal::new(TestBackend::new(72, 24)).unwrap();
term.draw(|f| render(f, app)).unwrap();
let buf = term.backend().buffer();
(0..72u16).any(|x| buf.cell((x, 0)).unwrap().bg == color)
};
assert!(chip_bg(&mut app, Color::White), "TREE チップの白背景が無い");
assert!(!chip_bg(&mut app, Color::Red), "通常で赤チップが出ている");
app.start_delete();
assert!(chip_bg(&mut app, Color::Red), "DELETE チップの赤背景が無い");
assert!(
chip_bg(&mut app, Color::White),
"外側 TREE チップが消えている"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn footer_reflects_internal_mode() {
let dir = std::env::temp_dir().join("konoma_footer_mode_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("a.txt"), b"x").unwrap();
let mut app = App::new(dir.clone(), Config::default()).unwrap();
let footer = |app: &mut App| row_text(app, 72, 24, 23);
assert!(footer(&mut app).contains("jk:move"), "通常フッター");
app.enter_visual();
let f = footer(&mut app);
assert!(
f.contains("extend") && f.contains("Space:ops"),
"ビジュアルフッター: {f}"
);
app.exit_visual_cancel();
app.start_delete();
let f = footer(&mut app);
assert!(
f.contains("y:Trash") && f.contains("!:"),
"削除フッター: {f}"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn no_bg_by_default_keeps_terminal_default() {
let dir = std::env::temp_dir().join("konoma_ui_nobg_test");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("a.txt"), b"hi").unwrap();
let mut app = App::new(dir.clone(), Config::default()).unwrap();
let mut term = Terminal::new(TestBackend::new(24, 8)).unwrap();
term.draw(|f| render(f, &mut app)).unwrap();
let buf = term.backend().buffer();
let body_painted = (2..8u16)
.flat_map(|y| (0..24u16).map(move |x| (x, y)))
.any(|(x, y)| buf.cell((x, y)).unwrap().bg != Color::Reset);
assert!(!body_painted, "本文領域に背景が塗られている");
let total = buf.content().len();
let reset = buf
.content()
.iter()
.filter(|c| c.bg == Color::Reset)
.count();
assert!(
reset * 100 >= total * 85,
"全体下地が塗られている: {reset}/{total}"
);
std::fs::remove_dir_all(&dir).ok();
}
}