Phase 6: VBDOS-Theme umsetzen und Change 05a archivieren

This commit is contained in:
2026-09-07 20:25:55 +02:00
parent 69f2002481
commit 5aa920b768
22 changed files with 686 additions and 79 deletions

View File

@@ -1,7 +1,7 @@
use crossterm::event::{
Event, KeyCode as K, KeyEvent, KeyModifiers as M, MouseButton, MouseEvent, MouseEventKind,
};
use ratatui::{backend::TestBackend, style::Color, Terminal};
use ratatui::{backend::TestBackend, Terminal};
use std::{
fs,
path::PathBuf,
@@ -105,11 +105,11 @@ fn start_snapshot_and_reference_menus_have_all_commands() {
&& text.contains("Copyright")
&& text.contains("00001:001")
);
assert_eq!(b[(4, 1)].bg, Color::Magenta);
assert_eq!(b[(4, 1)].fg, Color::White);
assert_eq!(b[(4, 2)].bg, Color::Blue);
assert_eq!(b[(1, 29)].bg, Color::Cyan);
assert_eq!(b[(1, 0)].bg, Color::Gray);
assert_eq!(b[(4, 1)].bg, tb_ide::render::dos(5));
assert_eq!(b[(4, 1)].fg, tb_ide::render::dos(15));
assert_eq!(b[(4, 2)].bg, tb_ide::render::dos(1));
assert_eq!(b[(1, 29)].bg, tb_ide::render::dos(3));
assert_eq!(b[(1, 0)].bg, tb_ide::render::dos(7));
plain(&mut app, K::Char('x'));
assert_eq!(
app.project
@@ -663,7 +663,7 @@ fn options_apply_persist_across_bases_and_preserve_unsaved_values_on_error() {
plain(&mut app, K::Enter);
assert!(app.dialog.is_none());
let (_, b) = draw(&mut app);
assert_eq!(b[(4, 1)].bg, Color::Yellow);
assert_eq!(b[(4, 1)].bg, tb_ide::render::dos(6));
assert_eq!(app.options.tab_width, 4);
let include = t.0.join("includes");
fs::create_dir(&include).unwrap();
@@ -924,3 +924,228 @@ fn finalization_failure_is_reported_and_preserves_existing_output() {
.to_string_lossy()
.starts_with(".tb-export-")));
}
// Prüft tatsächlich gerenderte Zellen, einschließlich geerbter Hintergründe.
fn theme_rgb(color: ratatui::style::Color) -> (u8, u8, u8) {
use ratatui::style::Color;
match color {
Color::Rgb(r, g, b) => (r, g, b),
Color::Indexed(n @ 16..=231) => {
let n = n - 16;
let channel = |v: u8| if v == 0 { 0 } else { 55 + v * 40 };
(channel(n / 36), channel(n / 6 % 6), channel(n % 6))
}
Color::Indexed(n @ 232..=255) => {
let v = 8 + (n - 232) * 10;
(v, v, v)
}
_ => {
let n = (0..16)
.find(|n| tb_ide::render::dos_color(*n, 16) == color)
.unwrap_or_else(|| panic!("Unbestimmte IDE-Farbe: {color:?}"));
theme_rgb(tb_ide::render::dos_color(n, u16::MAX))
}
}
}
fn theme_contrast(fg: ratatui::style::Color, bg: ratatui::style::Color) -> f64 {
let luminance = |color| {
let (r, g, b) = theme_rgb(color);
let linear = |v: u8| {
let v = f64::from(v) / 255.0;
if v <= 0.04045 {
v / 12.92
} else {
((v + 0.055) / 1.055).powf(2.4)
}
};
0.2126 * linear(r) + 0.7152 * linear(g) + 0.0722 * linear(b)
};
let (a, b) = (luminance(fg), luminance(bg));
(a.max(b) + 0.05) / (a.min(b) + 0.05)
}
fn assert_theme(app: &mut App, context: &str) {
let (_, b) = draw(app);
for (i, c) in b.content.iter().enumerate() {
if !c.symbol().trim().is_empty() {
let ratio = theme_contrast(c.fg, c.bg);
assert!(
ratio >= 4.5,
"{context} ({},{}): {:?} {:?}/{:?}: {ratio:.2}:1",
i % app.size.0 as usize,
i / app.size.0 as usize,
c.symbol(),
c.fg,
c.bg
);
}
}
}
#[test]
fn theme_contrast_covers_rendered_states_and_rejects_bad_pairs() {
use tb_ide::render::dos_color;
for colors in [256, u16::MAX] {
for (fg, bg) in tb_ide::options::Options::default().colors {
assert!(theme_contrast(dos_color(fg, colors), dos_color(bg, colors)) >= 4.5);
}
assert!(theme_contrast(dos_color(15, colors), dos_color(7, colors)) < 4.5);
for n in 0..16 {
let fg = if matches!(n, 0 | 1 | 4 | 5 | 6 | 8 | 9) {
15
} else {
0
};
assert!(
theme_contrast(dos_color(fg, colors), dos_color(n, colors)) >= 4.5,
"Palettenbeschriftung {n}"
);
}
}
let t = Temp::new();
let mut app = t.app();
app.options.syntax_checking = false;
type_text(&mut app, "PRINT 42");
assert_theme(&mut app, "Editor");
key(&mut app, K::Char('a'), M::CONTROL);
assert_theme(&mut app, "Textauswahl");
for m in 0..app.menus().len() {
for i in 0..app.menu_entries(m).len() {
app.menu = Some((m, i));
assert_theme(&mut app, "Menü einschließlich deaktivierter Einträge");
}
}
app.menu = None;
for command in [
Command::Display,
Command::Paths,
Command::MakeExe,
Command::MakeLibrary,
Command::Find,
Command::Replace,
Command::About,
] {
app.execute(command);
assert_theme(&mut app, "Dialog");
if let Some(d) = &mut app.dialog {
d.error = "Prüffehler: ungültiger Wert".into();
}
assert_theme(&mut app, "Dialogfehler");
app.dialog = None;
}
for command in [
Command::Project,
Command::Debug,
Command::Calls,
Command::HelpContents,
Command::Keyboard,
Command::UsingHelp,
] {
app.execute(command);
assert_theme(&mut app, "Projekt/Debugger/Hilfe");
plain(&mut app, K::Tab);
assert_theme(&mut app, "Fokus/Link");
}
let path = t.0.join("debug-colors.bas");
fs::write(&path, "x%=1\nx%=2\nPRINT x%\nEND\n").unwrap();
let mut debugger = t.app();
debugger.load_initial_project(path).unwrap();
plain(&mut debugger, K::Down);
debugger.execute(Command::Breakpoint);
assert_theme(&mut debugger, "Ungebundener Breakpoint");
debugger.execute(Command::Start);
for _ in 0..30 {
debugger.tick_execution(0);
if debugger.execution == tb_ide::app::Execution::Paused {
break;
}
}
assert_eq!(debugger.execution, tb_ide::app::Execution::Paused);
assert_theme(&mut debugger, "Gebundener Breakpoint und Ausführungszeile");
let (_, marks) = draw(&mut debugger);
assert!(marks
.content
.iter()
.any(|c| c.symbol() == "" && c.bg == tb_ide::render::dos(14)));
// Ein absichtlich schlechtes Standardpaar muss auch die Zellprüfung brechen.
let mut bad = t.app();
bad.options.colors[3] = (15, 7);
assert!(
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| assert_theme(
&mut bad,
"Negativprobe"
)))
.is_err()
);
}
#[test]
fn theme_color_commands_use_explicit_values_and_never_modify_terminal_palette() {
crossterm::style::force_color_output(true);
use ratatui::{
backend::{Backend, CrosstermBackend},
buffer::Cell,
style::Color,
};
use tb_ide::render::dos_color;
assert_eq!(dos_color(1, u16::MAX), Color::Rgb(0, 0, 170));
assert_eq!(dos_color(5, u16::MAX), Color::Rgb(170, 0, 170));
assert_eq!(dos_color(6, u16::MAX), Color::Rgb(170, 85, 0));
for colors in [16, 256, u16::MAX] {
for n in 0..16 {
let color = dos_color(n, colors);
if let Color::Indexed(index) = color {
assert!(index >= 16);
}
let mut cell = Cell::default();
cell.set_symbol("X")
.set_bg(color)
.set_fg(dos_color(15, colors));
let mut output = Vec::new();
CrosstermBackend::new(&mut output)
.draw(std::iter::once((0, 0, &cell)))
.unwrap();
let sequence = String::from_utf8(output).unwrap();
assert!(sequence.contains('X'));
if colors >= 256 {
assert!(sequence.contains("48;"));
}
if colors > 256 {
assert!(sequence.contains("48;2;"));
}
assert!(!sequence.contains("\x1b]")); // Keine globale Palettenänderung.
}
}
}
#[test]
fn theme_designer_chrome_and_handles_preserve_program_palette() {
let t = Temp::new();
let mut app = t.app();
app.execute(Command::NewForm);
let root = app.design_objects().unwrap()[0].0;
app.design_select(root, false).unwrap();
let (_, b) = draw(&mut app);
let handles: Vec<_> = b.content.iter().filter(|c| c.symbol() == "").collect();
assert!(!handles.is_empty());
for c in handles {
assert_eq!(
(c.fg, c.bg),
(tb_ide::render::dos(0), tb_ide::render::dos(14))
);
assert!(theme_contrast(c.fg, c.bg) >= 4.5);
}
let form = app.design_form().unwrap().clone();
let (_, screen) = tb_ide::designer::preview(&form, (100, 30)).unwrap();
let mut buf = ratatui::buffer::Buffer::empty(ratatui::layout::Rect::new(0, 0, 100, 30));
ratatui::widgets::Widget::render(tb_ui::screen::ScreenWidget(&screen), buf.area, &mut buf);
for (i, c) in buf.content.iter().enumerate() {
let original = screen.cell(i / 100 + 1, i % 100 + 1);
assert_eq!(c.bg, tb_ui::screen::basic_color(original.bg));
}
// Vorschau minimieren: ab hier gehören alle sichtbaren Zellen zur IDE.
app.execute(Command::Minimize);
for command in [Command::Toolbox, Command::Palette, Command::MenuDesign] {
app.execute(command);
assert_theme(&mut app, "Designer-Werkzeug");
}
}