use crate::{ app::{App, DialogKind, FieldValue, Hit, Mode, WindowKind, WindowState}, commands::Command, }; use ratatui::{ layout::Rect, style::{Color, Modifier, Style}, text::{Line, Span}, widgets::{Block, Borders, Clear, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState}, Frame, }; use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; pub fn dos(n: u8) -> Color { [ Color::Black, Color::Blue, Color::Green, Color::Cyan, Color::Red, Color::Magenta, Color::Yellow, Color::Gray, Color::DarkGray, Color::LightBlue, Color::LightGreen, Color::LightCyan, Color::LightRed, Color::LightMagenta, Color::LightYellow, Color::White, ][n.min(15) as usize] } fn style(app: &App, element: usize) -> Style { let (fg, bg) = app.options.colors[element]; Style::default().fg(dos(fg)).bg(dos(bg)) } fn put(f: &mut Frame, area: Rect, text: impl Into, style: Style) { f.render_widget(Paragraph::new(text.into()).style(style), area); } pub fn expanded(text: &str, tab: usize) -> String { let mut out = String::new(); let mut column = 0; for c in text.chars() { if c == '\t' { let n = tab - column % tab; out.extend(std::iter::repeat_n(' ', n)); column += n; } else { out.push(c); column += c.width().unwrap_or(0); } } out } pub const STATUS: [(&str, Command); 5] = [ ("", Command::UsingHelp), ("", Command::NextWindow), ("", Command::Code), ("", Command::Continue), ("", Command::Step), ]; impl App { pub fn render(&mut self, f: &mut Frame) { let area = f.area(); self.size = (area.width, area.height); self.hits.clear(); if area.width < 80 || area.height < 25 { f.render_widget(Clear, area); put( f, area, "Terminal Basic benötigt mindestens 80×25 Zellen.", Style::default(), ); return; } if self.session.fullscreen && self.dialog.is_none() && self.menu.is_none() { f.render_widget(tb_ui::screen::ScreenWidget(self.session.screen()), area); return; } let fill = self.options.desktop.to_string().repeat(area.width as usize); for row in 0..area.height { put( f, Rect::new(0, row, area.width, 1), fill.clone(), style(self, 1), ); } let mut windows = self.windows.clone(); windows.sort_by_key(|w| w.id == self.active); for w in windows { let rect = self.rect(&w); if rect.width < 2 || rect.height < 2 { continue; } let active = w.id == self.active; let content_style = style( self, if matches!(w.kind, WindowKind::Code(_)) { 2 } else { 5 }, ); f.render_widget( Block::default() .borders(Borders::ALL) .style(content_style) .border_style(style(self, 4)), rect, ); self.hits.push((rect, Hit::Window(w.id))); let title_style = style(self, if active { 3 } else { 4 }); put( f, Rect::new(rect.x, rect.y, rect.width, 1), format!("[≡] {}", self.title(&w)), title_style, ); if active { self.hits.push(( Rect::new(rect.x, rect.y, 3, 1), Hit::Command(Command::ControlMenu), )); } if rect.width > 10 { let x = rect.right() - 6; put(f, Rect::new(x, rect.y, 6, 1), "[_][↑]", title_style); if active { self.hits .push((Rect::new(x, rect.y, 3, 1), Hit::Command(Command::Minimize))); self.hits.push(( Rect::new(x + 3, rect.y, 3, 1), Hit::Command(Command::Maximize), )); } } if w.state == WindowState::Minimized { continue; } let inner = Rect::new(rect.x + 1, rect.y + 1, rect.width - 2, rect.height - 2); match w.kind { WindowKind::Code(view) => { let v = self.project.view(view).unwrap(); let doc = self.project.document(v.document()).unwrap(); if self.mode == Mode::Designer && Some(v.document()) == self.designer.document && matches!(doc.content(), tb_vm::project_io::Content::Form(_)) { self.design_render(f, inner); } else { let expansion = self.editor.expansions.get(&view); let code = expansion.map(String::as_str).unwrap_or(doc.code()); let lines: Vec<_> = code.split('\n').collect(); let count = lines.len(); let longest = lines .iter() .map(|s| expanded(s, self.options.tab_width).width()) .max() .unwrap_or(0); let selection = if expansion.is_none() { v.selection() } else { None }; let mut start = 0; let text = lines .iter() .enumerate() .filter_map(|(row, line)| { let base = start; start += line.len() + 1; if row < v.scroll_line { return None; } let mut col = 0; let mut spans = Vec::new(); for (i, c) in line.char_indices() { if c == '\r' { continue; } let width = if c == '\t' { self.options.tab_width - col % self.options.tab_width } else { c.width().unwrap_or(0) }; let end = col + width; if end > v.scroll_column && col < v.scroll_column + inner.width as usize { let selected = selection .as_ref() .is_some_and(|r| r.contains(&(base + i))); let text = if c == '\t' || col < v.scroll_column || end > v.scroll_column + inner.width as usize { " ".repeat( end.min(v.scroll_column + inner.width as usize) - col.max(v.scroll_column), ) } else { c.to_string() }; spans.push(Span::styled( text, if selected { content_style.add_modifier(Modifier::REVERSED) } else { content_style }, )); } else if width == 0 && !spans.is_empty() { spans.push(Span::raw(c.to_string())); } col = end; } Some(Line::from(spans)) }) .collect::>(); f.render_widget(Paragraph::new(text).style(content_style), inner); if count > inner.height as usize { f.render_stateful_widget( Scrollbar::new(ScrollbarOrientation::VerticalRight) .style(style(self, 4)), rect, &mut ScrollbarState::new(count) .position(v.scroll_line) .viewport_content_length(inner.height as usize), ); } if longest > inner.width as usize { f.render_stateful_widget( Scrollbar::new(ScrollbarOrientation::HorizontalBottom) .style(style(self, 4)), rect, &mut ScrollbarState::new(longest) .position(v.scroll_column) .viewport_content_length(inner.width as usize), ); } if active && expansion.is_none() && self.dialog.is_none() && self.menu.is_none() { let before = &doc.code()[..v.cursor.min(doc.code().len())]; let row = before.bytes().filter(|c| *c == b'\n').count(); let col = expanded( before.rsplit('\n').next().unwrap_or(""), self.options.tab_width, ) .width(); if row >= v.scroll_line && row - v.scroll_line < inner.height as usize && col >= v.scroll_column && col - v.scroll_column < inner.width as usize { f.set_cursor_position(( inner.x + (col - v.scroll_column) as u16, inner.y + (row - v.scroll_line) as u16, )); } } } } WindowKind::Project => { let button_width = 9.min(inner.width / 2); for (i, (text, c)) in [("Form", Command::Form), ("Code", Command::Code)] .into_iter() .enumerate() { let r = Rect::new( inner.x + i as u16 * button_width, inner.y, button_width, 3.min(inner.height), ); f.render_widget( Paragraph::new(text) .block( Block::bordered() .border_type(ratatui::widgets::BorderType::Double) .border_style(if active { Style::default().fg(Color::White) } else { style(self, 4) }), ) .style(content_style), r, ); self.hits.push((r, Hit::ProjectView(c == Command::Form))); } let members = self.project.members(); let visible = inner.height.saturating_sub(3) as usize; let start = self .selected_member .saturating_sub(visible.saturating_sub(1)); for (row, id) in members.iter().enumerate().skip(start).take(visible) { let r = Rect::new(inner.x, inner.y + 3 + (row - start) as u16, inner.width, 1); let doc = self.project.document(*id).unwrap(); let text = format!( "{}{}", if self.project.startup() == Some(*id) { "▶ " } else { " " }, doc.source_path() .file_name() .unwrap_or_default() .to_string_lossy() ); put( f, r, text, if row == self.selected_member { Style::default().fg(Color::White).bg(Color::Black) } else { content_style }, ); self.hits.push((r, Hit::ProjectMember(row))); } } kind @ (WindowKind::Toolbox | WindowKind::Palette | WindowKind::MenuDesign) => { self.design_tool_render(f, kind, inner) } WindowKind::Output => { f.render_widget(tb_ui::screen::ScreenWidget(self.session.screen()), inner) } kind => put( f, inner, match kind { WindowKind::Help => "Help · Inhalte folgen in Change 07", _ => "Debugger-Inhalte folgen in Change 06", }, content_style, ), } } if self.mode == Mode::Environment { let row = area.height - 1; put( f, Rect::new(0, row, area.width, 1), " ".repeat(area.width as usize), style(self, 6), ); let mut x = 0; for (label, c) in STATUS { let width = label.len() as u16; if x + width > area.width.saturating_sub(11) { break; } put(f, Rect::new(x, row, width, 1), label, style(self, 6)); self.hits .push((Rect::new(x, row, width, 1), Hit::Command(c))); x += width + 1; } let pos = self .active_window() .and_then(|w| { if let WindowKind::Code(v) = w.kind { self.project.view(v).ok() } else { None } }) .map(|v| { let code = self.project.document(v.document()).unwrap().code(); let before = &code[..v.cursor.min(code.len())]; format!( "{:05}:{:03}", before.bytes().filter(|c| *c == b'\n').count() + 1, expanded( before.rsplit('\n').next().unwrap_or(""), self.options.tab_width ) .width() + 1 ) }) .unwrap_or_else(|| "00001:001".into()); put(f, Rect::new(area.width - 9, row, 9, 1), pos, style(self, 6)); put( f, Rect::new(0, row - 1, area.width, 1), self.message.clone(), style(self, 1), ); } if self.mode == Mode::Designer && self.properties { let (left, geometry) = self.design_bar(); let width = (geometry.width() as u16 + 1).min(area.width); put( f, Rect::new(0, 0, area.width - width, 1), left, style(self, 0), ); put( f, Rect::new(area.width - width, 0, width, 1), geometry, style(self, 0), ); self.hits.push(( Rect::new(0, 0, area.width / 2, 1), Hit::DesignAction("property"), )); self.hits.push(( Rect::new(area.width / 2, 0, area.width - area.width / 2, 1), Hit::DesignAction("value"), )); } else { put( f, Rect::new(0, 0, area.width, 1), " ".repeat(area.width as usize), style(self, 0), ); let menus = self.menus(); let mut x = 0; for (i, m) in menus.iter().enumerate() { if m.title == "Help" { x = area.width - 6; } let r = Rect::new(x, 0, m.title.len() as u16 + 2, 1); let spans = vec![ Span::raw(" "), Span::styled( &m.title[..1], style(self, 0).add_modifier(Modifier::UNDERLINED), ), Span::raw(format!("{} ", &m.title[1..])), ]; f.render_widget( Paragraph::new(Line::from(spans)).style( if self.menu.is_some_and(|(n, _)| n == i) { Style::default().fg(Color::White).bg(Color::Black) } else { style(self, 0) }, ), r, ); self.hits.push((r, Hit::Menu(i))); x += r.width; } } if let Some((menu, selected)) = self.menu { let entries = self.menu_entries(menu); 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 } else { menus .iter() .take(menu) .map(|m| m.title.len() as u16 + 2) .sum::() .min(area.width - width) }; 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); 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) { let disabled = c.and_then(|c| self.availability(c)); let st = if disabled.is_some() { Style::default().fg(Color::DarkGray).bg(Color::Gray) } else if i == selected { Style::default().fg(Color::White).bg(Color::Black) } else { style(self, 0) }; let r = Rect::new(x + 1, 2 + (i - start) as u16, width - 2, 1); let label = if *c == Some(Command::SyntaxChecking) && self.options.syntax_checking { format!("• {text}") } else { text.clone() }; let mut spans = Vec::new(); let mut underline = false; for c in label.chars() { if c == '&' { underline = true; continue; } spans.push(Span::styled( c.to_string(), if underline { st.add_modifier(Modifier::UNDERLINED) } else { st }, )); underline = false; } f.render_widget(Paragraph::new(Line::from(spans)).style(st), r); self.hits.push((r, Hit::MenuItem(i))); } if let Some((_, Some(c))) = entries.get(selected) { if let Some(reason) = self.availability(*c) { put( f, Rect::new(0, area.height - 2, area.width, 1), reason, style(self, 0), ); } } } if let Some(d) = &self.dialog { let width = 76.min(area.width - 2); let height = (d.fields.len() as u16 + 8).min(area.height - 2); let rect = Rect::new( (area.width - width) / 2, (area.height - height) / 2, width, height, ); let st = style(self, 5); f.render_widget(Clear, rect); f.render_widget(Block::bordered().title(d.title.clone()).style(st), rect); let visible = height.saturating_sub(7) as usize; let start = d .focus .min(d.fields.len().saturating_sub(1)) .saturating_sub(visible.saturating_sub(1)); for (i, field) in d.fields.iter().enumerate().skip(start).take(visible) { let r = Rect::new(rect.x + 2, rect.y + 1 + (i - start) as u16, width - 4, 1); let value = match &field.value { FieldValue::Choice { .. } => format!("◄ {} ►", field.string()), FieldValue::Toggle(v) => { if *v { "[x]".into() } else { "[ ]".into() } } _ => field.string(), }; let label = format!("{}: {}", field.label, value); let selected = d.focus == i; let field_style = if selected { Style::default().fg(Color::White).bg(Color::Black) } else { st }; let caret = if let FieldValue::Text(text) = &field.value { field.label.width() + 2 + text[..field.cursor].width() } else { 0 }; let scroll = if selected { caret.saturating_sub(r.width.saturating_sub(1) as usize) } else { 0 }; f.render_widget( Paragraph::new(label) .style(field_style) .scroll((0, scroll.min(u16::MAX as usize) as u16)), r, ); if selected && matches!(field.value, FieldValue::Text(_)) && !(matches!(d.kind, DialogKind::Export(_)) && i == 0) { f.set_cursor_position((r.x + (caret - scroll) as u16, r.y)); } self.hits.push((r, Hit::DialogField(i))); } let export = matches!(d.kind, DialogKind::Export(_)); let submit = Rect::new(rect.x + 2, rect.bottom() - 3, 16, 1); let cancel = Rect::new(rect.x + 20, rect.bottom() - 3, 14, 1); put( f, submit, if export { "[Prüfen]" } else { "[OK / Enter]" }, if d.focus == d.fields.len() { Style::default().fg(Color::White).bg(Color::Black) } else { st }, ); put(f, cancel, "[Abbrechen]", st); self.hits.push((submit, Hit::DialogSubmit)); self.hits.push((cancel, Hit::DialogCancel)); if export { put( f, Rect::new(rect.x + 36, rect.bottom() - 3, width - 38, 1), "[Erzeugen: Phase 6]", Style::default().fg(Color::DarkGray).bg(Color::Gray), ); } let mut status = d.error.clone(); if export { if let Some(request) = &d.request { status = format!( "{}: {} · {}", d.export_status.text(), request.path.display(), d.error ); } else if status.is_empty() { status = crate::export::UNAVAILABLE.into(); } } f.render_widget( Paragraph::new(status) .wrap(ratatui::widgets::Wrap { trim: false }) .style(st), Rect::new(rect.x + 2, rect.bottom() - 6, width - 4, 3), ); put( f, Rect::new(rect.x + 2, rect.bottom() - 2, width - 4, 1), "Tab: Feld · ◄/►: Auswahl · F2: Dateiwahl · Esc: Abbrechen", st, ); } } }