Phase 6-07a: Fensterlokale Control-Menüs und Windows-Testkorrekturen

Control-Menüs öffnen für alle Fensterarten am [≡]-Symbol des aktiven
Fensters: eigener Auswahlzustand statt Kopplung an die Menüleiste,
bevorzugt unter der Titelzeile, bei Platzmangel nach oben aufgeklappt,
am rechten Rand nur so weit nach links wie nötig. Regressionstest über
TestBackend für Code-, Projekt-, Output-, Immediate-, Debug- und
Hilfefenster, maximiert, minimiert, Rand und Maus.

Nebenbefunde der Windows-Testausführung: Temporärdatei vor sync_all
schreibend öffnen (Zugriff verweigert), relative Projektverweise immer
mit / schreiben, zeilenendenneutrale Vergleiche in Referenzmatrix-,
Kompatibilitäts- und MAK-Beispieltests, DriveListBox-Zeichenbild
plattformneutral. Korpus-Sollausgaben per .gitattributes auf LF.

Specs ide-oberflaeche und ide-projekte synchronisiert, Change archiviert.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-09-08 11:02:40 +02:00
parent 6e742d96b1
commit 5a1a284505
22 changed files with 572 additions and 107 deletions

View File

@@ -291,7 +291,7 @@ pub struct App {
pub active: u64,
pub size: (u16, u16),
pub menu: Option<(usize, usize)>,
pub control_menu: bool,
pub control_menu: Option<usize>,
pub dialog: Option<Dialog>,
pub properties: bool,
pub value_focus: bool,
@@ -339,7 +339,7 @@ impl App {
active: 0,
size,
menu: None,
control_menu: false,
control_menu: None,
dialog: None,
properties: false,
value_focus: false,
@@ -401,6 +401,30 @@ impl App {
WindowState::Normal => clamp(w.normal, area),
}
}
/// Rechteck des Control-Menü-Popups, für alle Fensterarten nach derselben Regel: linke
/// Spalte gleich Spalte des `[≡]`-Symbols, bevorzugt direkt unter der Titelzeile; passt es
/// dort nicht, klappt es nach oben und endet direkt über der Titelzeile; passt es beidseitig
/// nicht, gewinnt die Seite mit mehr Platz. Rechts wird nur so weit nach links gerückt, dass
/// es in die Arbeitsfläche passt und die Symbolspalte überdeckt bleibt.
pub fn control_menu_rect(&self, width: u16, height: u16) -> Rect {
let area = self.area();
let anchor = self.active_window().map(|w| self.rect(w)).unwrap_or(area);
let below = area.bottom().saturating_sub(anchor.y + 1);
let above = anchor.y.saturating_sub(area.y);
let (y, height) = if height <= below {
(anchor.y + 1, height)
} else if height <= above {
(anchor.y - height, height)
} else if below >= above {
(anchor.y + 1, below.max(3))
} else {
let h = above.max(3);
(anchor.y.saturating_sub(h), h)
};
let width = width.min(area.width);
let x = anchor.x.min(area.right().saturating_sub(width));
Rect::new(x, y, width, height)
}
fn add_window(&mut self, kind: WindowKind, normal: Rect) -> u64 {
let id = self.next_window;
self.next_window += 1;
@@ -569,8 +593,7 @@ impl App {
self.message = reason;
return;
}
self.menu = None;
self.control_menu = false;
self.close_menus();
if let Err(e) = self.action(command) {
self.message = format!("{e:#}");
if self.mode == Mode::Designer {
@@ -792,8 +815,8 @@ impl App {
}
}
ControlMenu => {
self.control_menu = true;
self.menu = Some((0, 0));
// Fensterlokal: kein Menüleistentitel wird ausgewählt.
self.control_menu = Some(0);
}
FocusWindow(id) => {
if self.windows.iter().any(|w| w.id == id) {
@@ -1539,8 +1562,8 @@ impl App {
&& !self.program_focus()
&& self.dialog.is_none()
{
if let Some((m, i)) = self.menu {
if let Some((_, Some(command))) = self.menu_entries(m).get(i) {
if let Some((entries, i)) = self.open_entries() {
if let Some((_, Some(command))) = entries.get(i) {
self.help_menu_context(*command);
}
} else {
@@ -1549,7 +1572,7 @@ impl App {
return;
}
if self.dialog.is_none()
&& self.menu.is_none()
&& !self.menu_open()
&& self
.active_window()
.is_some_and(|w| w.kind == WindowKind::Help)
@@ -1581,7 +1604,7 @@ impl App {
}
_ => {}
}
if self.dialog.is_none() && self.menu.is_none() && self.program_focus() {
if self.dialog.is_none() && !self.menu_open() && self.program_focus() {
if self.session.fullscreen {
self.basic_events.push(Event::Mouse(mouse));
return;
@@ -1604,7 +1627,7 @@ impl App {
}
}
if mouse.kind == MouseEventKind::Down(MouseButton::Right) {
if self.dialog.is_none() && self.menu.is_none() && self.options.right_help {
if self.dialog.is_none() && !self.menu_open() && self.options.right_help {
self.execute(Command::Topic);
}
return;
@@ -1619,7 +1642,7 @@ impl App {
if let Some(hit) = hit {
self.click(hit);
}
} else if self.dialog.is_none() && self.menu.is_none() {
} else if self.dialog.is_none() && !self.menu_open() {
if let Some(Window {
kind: WindowKind::Code(v),
..
@@ -1641,7 +1664,7 @@ impl App {
self.basic_events.push(event);
} else if let Event::Paste(text) = event {
if self.dialog.is_none()
&& self.menu.is_none()
&& !self.menu_open()
&& self
.active_window()
.is_some_and(|w| w.kind == WindowKind::Immediate)
@@ -1649,7 +1672,7 @@ impl App {
self.debugger.immediate.push_str(&text);
return;
}
if self.dialog.is_none() && self.menu.is_none() && self.mode == Mode::Environment {
if self.dialog.is_none() && !self.menu_open() && self.mode == Mode::Environment {
if let Err(e) = self.editor_insert(&text) {
self.message = e.to_string();
}
@@ -1676,19 +1699,17 @@ impl App {
}
return;
}
if self.menu.is_some() {
if self.menu_open() {
match hit {
Hit::Menu(i) => self.menu = Some((i, 0)),
Hit::Menu(i) => {
self.control_menu = None;
self.menu = Some((i, 0));
}
Hit::MenuItem(i) => {
if let Some((menu, _)) = self.menu {
self.menu = Some((menu, i));
self.menu_enter();
}
}
_ => {
self.menu = None;
self.control_menu = false;
self.select_open(i);
self.menu_enter();
}
_ => self.close_menus(),
}
return;
}
@@ -1763,14 +1784,14 @@ impl App {
fn key(&mut self, key: KeyEvent) {
if self.dialog.is_none() && key.code == K::F(1) {
if key.modifiers.is_empty() {
if let Some((m, i)) = self.menu {
if let Some((_, Some(command))) = self.menu_entries(m).get(i) {
if let Some((entries, i)) = self.open_entries() {
if let Some((_, Some(command))) = entries.get(i) {
self.help_menu_context(*command);
return;
}
}
}
if self.menu.is_none() {
if !self.menu_open() {
if !self.help_key(key) {
self.execute(if key.modifiers.contains(M::SHIFT) {
Command::UsingHelp
@@ -1787,7 +1808,7 @@ impl App {
}
if self.editor.chord.is_some()
&& self.dialog.is_none()
&& self.menu.is_none()
&& !self.menu_open()
&& !self.program_focus()
{
if let Err(e) = self.editor_key(key) {
@@ -1828,7 +1849,7 @@ impl App {
self.execute(Command::MenuBar);
return;
}
if self.menu.is_some() {
if self.menu_open() {
if key.modifiers.contains(M::ALT) {
if let K::Char(c) = key.code {
if let Some(i) = self
@@ -1836,45 +1857,46 @@ impl App {
.iter()
.position(|m| m.mnemonic == c.to_ascii_lowercase())
{
self.control_menu = false;
self.control_menu = None;
self.menu = Some((i, 0));
return;
}
}
}
match key.code {
K::Esc => {
self.menu = None;
self.control_menu = false;
}
K::Left | K::Right if !self.control_menu => {
let n = self.menus().len();
let (m, _) = self.menu.unwrap();
self.menu = Some((
(m + n + if key.code == K::Right { 1 } else { n - 1 }) % n,
0,
));
K::Esc => self.close_menus(),
K::Left | K::Right => {
// Nur die Menüleiste wechselt seitlich; das Control-Menü bleibt stehen.
if let Some((m, _)) = self.menu {
let n = self.menus().len();
self.menu = Some((
(m + n + if key.code == K::Right { 1 } else { n - 1 }) % n,
0,
));
}
}
K::Up | K::Down => {
let (m, i) = self.menu.unwrap();
let n = self.menu_entries(m).len();
if n > 0 {
self.menu =
Some((m, (i + n + if key.code == K::Down { 1 } else { n - 1 }) % n));
if let Some((entries, i)) = self.open_entries() {
let n = entries.len();
if n > 0 {
self.select_open(
(i + n + if key.code == K::Down { 1 } else { n - 1 }) % n,
);
}
}
}
K::Enter => self.menu_enter(),
K::Char(c) => {
let (m, _) = self.menu.unwrap();
let entries = self.menu_entries(m);
if let Some(i) = entries.iter().position(|(label, _)| {
label
.split_once('&')
.and_then(|(_, s)| s.chars().next())
.is_some_and(|v| v.eq_ignore_ascii_case(&c))
}) {
self.menu = Some((m, i));
self.menu_enter();
if let Some((entries, _)) = self.open_entries() {
if let Some(i) = entries.iter().position(|(label, _)| {
label
.split_once('&')
.and_then(|(_, s)| s.chars().next())
.is_some_and(|v| v.eq_ignore_ascii_case(&c))
}) {
self.select_open(i);
self.menu_enter();
}
}
}
_ => {}
@@ -2123,17 +2145,40 @@ impl App {
}
self.editor_key(key)
}
pub fn menu_entries(&self, index: usize) -> Vec<(String, Option<Command>)> {
if self.control_menu {
return vec![
("&Restore".into(), Some(Command::Restore)),
("&Move".into(), Some(Command::MoveWindow)),
("&Size".into(), Some(Command::SizeWindow)),
("Mi&nimize".into(), Some(Command::Minimize)),
("Ma&ximize".into(), Some(Command::Maximize)),
("&Close".into(), Some(Command::CloseWindow)),
];
/// Einträge des fensterlokalen Control-Menüs, für alle Fensterarten gleich.
pub fn control_entries(&self) -> MenuEntries {
vec![
("&Restore".into(), Some(Command::Restore)),
("&Move".into(), Some(Command::MoveWindow)),
("&Size".into(), Some(Command::SizeWindow)),
("Mi&nimize".into(), Some(Command::Minimize)),
("Ma&ximize".into(), Some(Command::Maximize)),
("&Close".into(), Some(Command::CloseWindow)),
]
}
/// Ist ein Menü (Menüleiste oder Control-Menü) geöffnet?
pub fn menu_open(&self) -> bool {
self.menu.is_some() || self.control_menu.is_some()
}
/// Einträge und Auswahl der gerade offenen Liste: Control-Menü hat Vorrang vor der Menüleiste.
pub fn open_entries(&self) -> Option<(MenuEntries, usize)> {
if let Some(i) = self.control_menu {
return Some((self.control_entries(), i));
}
self.menu.map(|(m, i)| (self.menu_entries(m), i))
}
fn select_open(&mut self, i: usize) {
if self.control_menu.is_some() {
self.control_menu = Some(i);
} else if let Some((m, _)) = self.menu {
self.menu = Some((m, i));
}
}
pub(crate) fn close_menus(&mut self) {
self.menu = None;
self.control_menu = None;
}
pub fn menu_entries(&self, index: usize) -> MenuEntries {
let menus = self.menus();
let mut entries = menus[index]
.items
@@ -2151,13 +2196,15 @@ impl App {
entries
}
fn menu_enter(&mut self) {
if let Some((m, i)) = self.menu {
if let Some((_, Some(c))) = self.menu_entries(m).get(i) {
if let Some((entries, i)) = self.open_entries() {
if let Some((_, Some(c))) = entries.get(i) {
self.execute(*c);
}
}
}
}
/// Beschriftungen und Befehle einer aufgeklappten Menüliste.
pub type MenuEntries = Vec<(String, Option<Command>)>;
pub fn clamp(r: Rect, area: Rect) -> Rect {
let width = r.width.max(12).min(area.width);
let height = r.height.max(4).min(area.height);

View File

@@ -1481,7 +1481,7 @@ impl App {
Ok(())
}
pub(crate) fn design_mouse(&mut self, m: MouseEvent) -> Result<bool> {
if self.mode != Mode::Designer || self.dialog.is_some() || self.menu.is_some() {
if self.mode != Mode::Designer || self.dialog.is_some() || self.menu_open() {
return Ok(false);
}
if let Some((property, color)) = self.designer.paint {

View File

@@ -796,8 +796,7 @@ impl App {
}
pub(crate) fn help_menu_context(&mut self, command: Command) {
let target = command_target(command, self.mode == Mode::Designer);
self.menu = None;
self.control_menu = false;
self.close_menus();
self.help_show();
match Catalog::embedded().resolve("", target) {
Ok((p, b)) => self.help.visit(p, b, None),

View File

@@ -64,7 +64,7 @@ impl App {
);
return;
}
if self.session.fullscreen && self.dialog.is_none() && self.menu.is_none() {
if self.session.fullscreen && self.dialog.is_none() && !self.menu_open() {
f.render_widget(tb_ui::screen::ScreenWidget(self.session.screen()), area);
return;
}
@@ -317,7 +317,7 @@ impl App {
if active
&& expansion.is_none()
&& self.dialog.is_none()
&& self.menu.is_none()
&& !self.menu_open()
{
let before = &doc.code()[..v.cursor.min(doc.code().len())];
let row = before.bytes().filter(|c| *c == b'\n').count();
@@ -523,26 +523,30 @@ impl App {
x += r.width;
}
}
if let Some((menu, selected)) = self.menu {
let entries = self.menu_entries(menu);
if let Some((entries, selected)) = self.open_entries() {
let width = (entries.iter().map(|(s, _)| s.width()).max().unwrap_or(12) as u16 + 4)
.min(area.width);
let menus = self.menus();
let x = if self.control_menu {
self.active_window().map(|w| self.rect(w).x).unwrap_or(0)
} else if menus[menu].title == "Help" {
area.width - width
// Control-Menü hängt am Fenstersymbol, Menüleistenmenüs unter ihrem Titel.
let (x, y, visible) = if self.control_menu.is_some() {
let r = self.control_menu_rect(width, entries.len() as u16 + 2);
(r.x, r.y, r.height.saturating_sub(2).max(1) as usize)
} else {
menus
.iter()
.take(menu)
.map(|m| m.title.len() as u16 + 2)
.sum::<u16>()
.min(area.width - width)
let menu = self.menu.map(|(m, _)| m).unwrap_or(0);
let menus = self.menus();
let x = if menus[menu].title == "Help" {
area.width - width
} else {
menus
.iter()
.take(menu)
.map(|m| m.title.len() as u16 + 2)
.sum::<u16>()
.min(area.width - width)
};
(x, 1, (area.height - 4) as usize)
};
let visible = (area.height - 4) as usize;
let start = selected.saturating_sub(visible - 1);
let rect = Rect::new(x, 1, width, (entries.len().min(visible) + 2) as u16);
let rect = Rect::new(x, y, width, (entries.len().min(visible) + 2) as u16);
f.render_widget(Clear, rect);
f.render_widget(Block::bordered().style(style(self, 0)), rect);
for (i, (text, c)) in entries.iter().enumerate().skip(start).take(visible) {
@@ -554,7 +558,7 @@ impl App {
} else {
style(self, 0)
};
let r = Rect::new(x + 1, 2 + (i - start) as u16, width - 2, 1);
let r = Rect::new(x + 1, y + 1 + (i - start) as u16, width - 2, 1);
let label = if disabled.is_some() {
format!("× {text}")
} else if *c == Some(Command::SyntaxChecking) && self.options.syntax_checking {

View File

@@ -302,7 +302,7 @@ fn windows_restore_geometry_and_resize_without_document_loss() {
assert!(r.right() <= 80 && r.bottom() <= 24);
}
key(&mut app, K::Char('-'), M::ALT);
assert!(app.control_menu);
assert!(app.control_menu.is_some());
plain(&mut app, K::Esc);
key(&mut app, K::F(4), M::CONTROL);
assert_eq!(app.windows.len(), 2);
@@ -1180,3 +1180,182 @@ fn theme_windows_erase_desktop_and_underlying_windows_on_every_redraw() {
}
}
}
/// Sucht die Zelle, an der `text` in der Ausgabe beginnt.
fn find_text(b: &ratatui::buffer::Buffer, text: &str) -> Option<(u16, u16)> {
let chars: Vec<String> = text.chars().map(|c| c.to_string()).collect();
for y in 0..b.area.height {
for x in 0..b.area.width.saturating_sub(chars.len() as u16) {
if chars
.iter()
.enumerate()
.all(|(i, c)| b[(x + i as u16, y)].symbol() == c)
{
return Some((x, y));
}
}
}
None
}
/// Öffnet das Control-Menü per Alt+Minus und prüft die Anheftregel: linke Spalte gleich
/// Symbolspalte (oder so weit links, dass es passt und das Symbol überdeckt), Rahmen grenzt
/// unten oder oben an die Titelzeile, vollständig in der Arbeitsfläche, kein Menüleistentitel
/// hervorgehoben. Liefert das Rechteck des Aufklappbereichs.
fn assert_control_menu_attached(app: &mut App, context: &str) -> ratatui::layout::Rect {
let r = app.rect(app.active_window().unwrap());
let area = app.area();
key(app, K::Char('-'), M::ALT);
assert!(app.control_menu.is_some(), "{context}: Control-Menü offen");
assert_eq!(
app.menu, None,
"{context}: Menüleiste darf nicht ausgewählt sein"
);
let (_, b) = draw(app);
assert_eq!(
(b[(1, 0)].fg, b[(1, 0)].bg),
(tb_ide::render::dos(0), tb_ide::render::dos(7)),
"{context}: „File“ darf nicht hervorgehoben sein"
);
let (rx, ry) = find_text(&b, "Restore").unwrap_or_else(|| panic!("{context}: Restore"));
let (_, cy) = find_text(&b, "Close").unwrap_or_else(|| panic!("{context}: Close"));
let (x, top, bottom) = (rx - 1, ry - 1, cy + 1);
let mut right = x + 1;
while right < b.area.width && b[(right, top)].symbol() != "" {
right += 1;
}
let popup = ratatui::layout::Rect::new(x, top, right + 1 - x, bottom + 1 - top);
assert_eq!(b[(x, top)].symbol(), "", "{context}: linke obere Ecke");
assert_eq!(b[(x, bottom)].symbol(), "", "{context}: linke untere Ecke");
assert!(
popup.y >= area.y && popup.bottom() <= area.bottom() && popup.right() <= area.right(),
"{context}: Popup {popup:?} außerhalb der Arbeitsfläche {area:?}"
);
assert!(
popup.x <= r.x && r.x < popup.right(),
"{context}: Popup {popup:?} überdeckt die Symbolspalte von {r:?} nicht"
);
if r.x + popup.width <= area.right() {
assert_eq!(popup.x, r.x, "{context}: linke Spalte gleich Symbolspalte");
} else {
assert_eq!(
popup.right(),
area.right(),
"{context}: nur so weit links wie nötig"
);
}
if popup.height <= area.bottom() - (r.y + 1) {
assert_eq!(popup.y, r.y + 1, "{context}: direkt unter der Titelzeile");
} else {
assert_eq!(
popup.bottom(),
r.y,
"{context}: nach oben aufgeklappt bis an die Titelzeile"
);
}
popup
}
#[test]
fn control_menus_attach_to_the_symbol_of_every_window() {
let t = Temp::new();
let mut app = t.app();
let area = app.area();
let code = app.active;
let p = assert_control_menu_attached(&mut app, "Code");
let r = app.rect(app.active_window().unwrap());
assert_eq!((p.x, p.y), (r.x, r.y + 1));
plain(&mut app, K::Esc);
assert!(app.control_menu.is_none());
assert_eq!(app.active, code);
plain(&mut app, K::F(6));
assert_eq!(app.active_window().unwrap().kind, WindowKind::Project);
assert_control_menu_attached(&mut app, "Project");
plain(&mut app, K::Esc);
menu(&mut app, Command::Output);
assert_eq!(app.active_window().unwrap().kind, WindowKind::Output);
assert_control_menu_attached(&mut app, "Output");
plain(&mut app, K::Esc);
menu(&mut app, Command::Immediate);
assert_eq!(app.active_window().unwrap().kind, WindowKind::Immediate);
assert_control_menu_attached(&mut app, "Immediate");
plain(&mut app, K::Esc);
menu(&mut app, Command::Debug);
assert_eq!(app.active_window().unwrap().kind, WindowKind::Debug);
assert_control_menu_attached(&mut app, "Debug");
plain(&mut app, K::Esc);
menu(&mut app, Command::HelpWindow);
assert_eq!(app.active_window().unwrap().kind, WindowKind::Help);
assert_control_menu_attached(&mut app, "Help");
plain(&mut app, K::Esc);
assert_eq!(app.active_window().unwrap().kind, WindowKind::Help);
menu(&mut app, Command::Immediate);
key(&mut app, K::F(10), M::CONTROL);
let p = assert_control_menu_attached(&mut app, "Maximized");
assert_eq!((p.x, p.y), (area.x, area.y + 1));
plain(&mut app, K::Esc);
key(&mut app, K::F(9), M::CONTROL);
assert_eq!(app.active_window().unwrap().state, WindowState::Minimized);
let r = app.rect(app.active_window().unwrap());
let p = assert_control_menu_attached(&mut app, "Minimized");
assert_eq!(
(p.x, p.bottom()),
(r.x, r.y),
"minimiertes Fenster klappt nach oben"
);
plain(&mut app, K::Esc);
key(&mut app, K::F(5), M::CONTROL);
key(&mut app, K::F(8), M::CONTROL);
for _ in 0..20 {
key(&mut app, K::Left, M::CONTROL);
}
plain(&mut app, K::Enter);
key(&mut app, K::F(7), M::CONTROL);
for _ in 0..30 {
key(&mut app, K::Right, M::CONTROL);
}
plain(&mut app, K::Enter);
let r = app.rect(app.active_window().unwrap());
assert_eq!(r.right(), area.right());
let p = assert_control_menu_attached(&mut app, "Rechter Rand");
assert!(p.x < r.x && p.right() == area.right());
plain(&mut app, K::Esc);
// Normales Fenster am unteren Rand: zu wenig Platz unter der Titelzeile, klappt nach oben.
key(&mut app, K::F(8), M::CONTROL);
for _ in 0..20 {
key(&mut app, K::Up, M::CONTROL);
}
plain(&mut app, K::Enter);
key(&mut app, K::F(7), M::CONTROL);
for _ in 0..30 {
key(&mut app, K::Down, M::CONTROL);
}
plain(&mut app, K::Enter);
let r = app.rect(app.active_window().unwrap());
assert_eq!(r.bottom(), area.bottom());
let p = assert_control_menu_attached(&mut app, "Unterer Rand");
assert_eq!(p.bottom(), r.y, "klappt nach oben");
plain(&mut app, K::Esc);
// Maus: Klick auf [≡] öffnet identisch, Klick auf einen Eintrag führt ihn aus.
key(&mut app, K::F(5), M::CONTROL);
draw(&mut app);
let r = app.rect(app.active_window().unwrap());
click(&mut app, r.x, r.y);
assert!(app.control_menu.is_some());
assert_eq!(app.menu, None);
let (_, b) = draw(&mut app);
let (mx, my) = find_text(&b, "Minimize").unwrap();
click(&mut app, mx, my);
assert!(app.control_menu.is_none());
assert_eq!(app.active_window().unwrap().state, WindowState::Minimized);
}