Files
TerminalBasic/crates/tb-ide/src/render.rs

640 lines
26 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
);
}
}
}