640 lines
26 KiB
Rust
640 lines
26 KiB
Rust
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<String>, 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] = [
|
||
("<Shift+F1=Help>", Command::UsingHelp),
|
||
("<F6=Window>", Command::NextWindow),
|
||
("<F2=Code>", Command::Code),
|
||
("<F5=Run>", Command::Continue),
|
||
("<F8=Step>", 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::<Vec<_>>();
|
||
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::<u16>()
|
||
.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,
|
||
);
|
||
}
|
||
}
|
||
}
|