refactor: organize host modules and document architecture with UML
Verify Tessera GUI / verify (push) Failing after 2m2s
Verify Tessera GUI / verify (push) Failing after 2m2s
This commit is contained in:
@@ -0,0 +1,198 @@
|
||||
use super::*;
|
||||
|
||||
pub(super) fn build(
|
||||
p: &mut Painter,
|
||||
state: &mut DeveloperToolsState,
|
||||
records: &[Exchange],
|
||||
viewport: Rect,
|
||||
) {
|
||||
let x = viewport.x;
|
||||
let w = viewport.width;
|
||||
let bottom = viewport.height - 22.0;
|
||||
|
||||
p.fill(Rect::new(x, 32.0, w, 30.0), BAR);
|
||||
p.enabled_button(
|
||||
Rect::new(x + 3.0, 32.0, 62.0, 29.0),
|
||||
"Clear",
|
||||
Action::ClearConsole,
|
||||
records.iter().any(|r| {
|
||||
r.id > state.console_after
|
||||
&& matches!(r.method.as_str(), "PUT" | "POST" | "DELETE")
|
||||
&& !r.path.ends_with("/events")
|
||||
}),
|
||||
);
|
||||
p.button(
|
||||
Rect::new(x + 68.0, 32.0, 106.0, 29.0),
|
||||
if state.follow {
|
||||
"Follow: on"
|
||||
} else {
|
||||
"Follow: off"
|
||||
},
|
||||
Action::Follow,
|
||||
state.follow,
|
||||
);
|
||||
p.button(
|
||||
Rect::new(x + 181.0, 32.0, 119.0, 29.0),
|
||||
"Reset session",
|
||||
Action::ResetSession,
|
||||
false,
|
||||
);
|
||||
if w > 520.0 {
|
||||
p.label(x + 309.0, 39.0, "app.main · live objects", MUTED, false);
|
||||
}
|
||||
let mut lines = Vec::new();
|
||||
for r in records.iter().filter(|r| {
|
||||
r.id > state.console_after
|
||||
&& (r.method == "PUT" || r.method == "POST" || r.method == "DELETE")
|
||||
&& !r.path.ends_with("/events")
|
||||
}) {
|
||||
let kind = if r.path.ends_with("/repl") {
|
||||
if r.method == "DELETE" {
|
||||
"RESET SESSION"
|
||||
} else {
|
||||
"REPL · live objects"
|
||||
}
|
||||
} else if r.path.ends_with("/evaluate") {
|
||||
"ISOLATED API EVALUATION"
|
||||
} else if r.method == "PUT" {
|
||||
"FULL SOURCE · live program"
|
||||
} else {
|
||||
"CLEAR DOCUMENT"
|
||||
};
|
||||
let local = r.origin == "Console" && r.path.ends_with("/repl") && r.method == "POST";
|
||||
if !local {
|
||||
let mut title = Line::new(
|
||||
format!("#{} {} {} {}", r.id, r.timestamp, r.origin, kind),
|
||||
MUTED,
|
||||
);
|
||||
title.divider = Some(Divider::Accent);
|
||||
title.action = Some(Action::Request(r.id));
|
||||
lines.push(title);
|
||||
}
|
||||
for (i, line) in r.request.split('\n').enumerate() {
|
||||
lines.push(Line::new(
|
||||
format!("{}{line}", if i == 0 { "> " } else { " " }),
|
||||
TEXT,
|
||||
));
|
||||
}
|
||||
let color = if r.status.is_some_and(|s| s >= 400) {
|
||||
ERROR
|
||||
} else {
|
||||
OK
|
||||
};
|
||||
if let Some(status) = r.status {
|
||||
if !local {
|
||||
lines.push(Line::new(
|
||||
format!("‹ {status} · {} ms", r.elapsed_ms),
|
||||
color,
|
||||
));
|
||||
}
|
||||
console_result(&mut lines, &r.response, color);
|
||||
} else {
|
||||
lines.push(Line::new(" Executing…", MUTED));
|
||||
}
|
||||
let mut separator = Line::new("", MUTED);
|
||||
separator.divider = Some(Divider::Neutral);
|
||||
lines.push(separator);
|
||||
}
|
||||
if lines.is_empty() {
|
||||
multiline(
|
||||
&mut lines,
|
||||
"Endor · live session\nCreate objects here; call page.render(...) to display.\n",
|
||||
MUTED,
|
||||
);
|
||||
}
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut hash = std::collections::hash_map::DefaultHasher::new();
|
||||
for line in &lines {
|
||||
line.value.hash(&mut hash);
|
||||
}
|
||||
let stamp = hash.finish();
|
||||
let output_changed = stamp != state.console_stamp;
|
||||
state.console_stamp = stamp;
|
||||
let first_input = lines.len();
|
||||
let area = Rect::new(x + 4.0, 63.0, w - 5.0, (bottom - 88.0).max(1.0));
|
||||
let mut input = state.input.clone();
|
||||
if state.focus == Some(Focus::Input) {
|
||||
input.insert_str(state.cursor, &state.preedit);
|
||||
}
|
||||
for (i, line) in input.split('\n').enumerate() {
|
||||
let mut line = Line::new(format!("{}{line}", if i == 0 { "> " } else { " " }), TEXT);
|
||||
line.selected = state.selection && state.focus == Some(Focus::Input);
|
||||
lines.push(line);
|
||||
}
|
||||
let prefix = text::measure("> ", TextStyle::code()).width;
|
||||
let before = &state.input[..state.cursor];
|
||||
let row = before.matches('\n').count() as f32;
|
||||
let column = before.rsplit('\n').next().unwrap_or("");
|
||||
let caret_x =
|
||||
prefix + text::measure(&format!("{column}{}", state.preedit), TextStyle::code()).width;
|
||||
let caret_y = (first_input as f32 + row) * ROW;
|
||||
let follow = state.follow && output_changed && !state.reveal_input;
|
||||
if state.reveal_input {
|
||||
let s = &mut state.scroll[Region::Transcript as usize];
|
||||
s.y = s.y.clamp(
|
||||
(caret_y + ROW + 16.0 - area.height).max(0.0).min(caret_y),
|
||||
caret_y,
|
||||
);
|
||||
s.x = s.x.clamp((caret_x + 24.0 - area.width).max(0.0), caret_x);
|
||||
state.reveal_input = false;
|
||||
}
|
||||
p.lines(area, Region::Transcript, &lines, state, follow);
|
||||
let scroll = state.scroll[Region::Transcript as usize];
|
||||
let origin = Point::new(
|
||||
area.x + 8.0 + prefix - scroll.x,
|
||||
area.y + 5.0 + first_input as f32 * ROW - scroll.y,
|
||||
);
|
||||
p.layout.input = Some(ConsoleInputLayout {
|
||||
viewport: area,
|
||||
origin,
|
||||
});
|
||||
let input_top = origin.y.max(area.y);
|
||||
let input_bottom =
|
||||
(origin.y + state.input.split('\n').count() as f32 * ROW).min(area.y + area.height - 9.0);
|
||||
if input_bottom > input_top {
|
||||
p.ui.inline_input(
|
||||
Rect::new(
|
||||
area.x,
|
||||
input_top,
|
||||
(area.width - 9.0).max(0.0),
|
||||
input_bottom - input_top,
|
||||
),
|
||||
Action::Focus(Focus::Input),
|
||||
);
|
||||
}
|
||||
if state.focus == Some(Focus::Input) {
|
||||
p.ui.list.push(DrawCommand::PushClip(Rect::new(
|
||||
area.x,
|
||||
area.y,
|
||||
(area.width - 9.0).max(0.0),
|
||||
(area.height - 9.0).max(0.0),
|
||||
)));
|
||||
p.fill(
|
||||
Rect::new(
|
||||
area.x + 8.0 + caret_x - scroll.x,
|
||||
origin.y + row * ROW + 1.0,
|
||||
1.0,
|
||||
ROW - 2.0,
|
||||
),
|
||||
TEXT,
|
||||
);
|
||||
p.ui.list.push(DrawCommand::PopClip);
|
||||
}
|
||||
p.label(
|
||||
x + 10.0,
|
||||
bottom - 23.0,
|
||||
if state.input_error.is_empty() {
|
||||
"Enter: run · Shift+Enter: newline · ⌘/Ctrl+↑↓: history"
|
||||
} else {
|
||||
&state.input_error
|
||||
},
|
||||
if state.input_error.is_empty() {
|
||||
MUTED
|
||||
} else {
|
||||
ERROR
|
||||
},
|
||||
false,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
use super::*;
|
||||
|
||||
pub(super) fn build(
|
||||
p: &mut Painter,
|
||||
state: &mut DeveloperToolsState,
|
||||
records: &[Exchange],
|
||||
viewport: Rect,
|
||||
) {
|
||||
let x = viewport.x;
|
||||
let w = viewport.width;
|
||||
let bottom = viewport.height - 22.0;
|
||||
|
||||
p.fill(Rect::new(x, 32.0, w, 30.0), BAR);
|
||||
p.enabled_button(
|
||||
Rect::new(x + 3.0, 32.0, 62.0, 29.0),
|
||||
"Clear",
|
||||
Action::ClearNetwork,
|
||||
records.iter().any(|r| r.id > state.network_after),
|
||||
);
|
||||
let filter_area = Rect::new(x + 72.0, 35.0, (w - 85.0).max(100.0), 24.0);
|
||||
p.ui.text_field(
|
||||
filter_area,
|
||||
Action::Focus(Focus::Filter),
|
||||
state.focus == Some(Focus::Filter),
|
||||
);
|
||||
p.label(
|
||||
filter_area.x + 7.0,
|
||||
filter_area.y + 3.0,
|
||||
&format!(
|
||||
"Filter: {}{}",
|
||||
state.filter,
|
||||
if state.focus == Some(Focus::Filter) {
|
||||
"│"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
),
|
||||
MUTED,
|
||||
false,
|
||||
);
|
||||
|
||||
p.label(
|
||||
x + 10.0,
|
||||
69.0,
|
||||
"METHOD STATUS TIME PATH",
|
||||
MUTED,
|
||||
true,
|
||||
);
|
||||
let divider = 92.0 + (bottom - 92.0) * state.network_fraction;
|
||||
let filter = state.filter.to_lowercase();
|
||||
let visible = records
|
||||
.iter()
|
||||
.filter(|r| r.id > state.network_after)
|
||||
.filter(|r| {
|
||||
format!(
|
||||
"{} {} {}",
|
||||
r.method,
|
||||
r.path,
|
||||
r.status.map_or("pending".into(), |s| s.to_string())
|
||||
)
|
||||
.to_lowercase()
|
||||
.contains(&filter)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let mut lines = Vec::new();
|
||||
for r in &visible {
|
||||
let mut l = Line::new(
|
||||
format!(
|
||||
"{:<7} {:<6} {:>5} ms {}",
|
||||
r.method,
|
||||
r.status.map_or("…".into(), |s| s.to_string()),
|
||||
r.elapsed_ms,
|
||||
r.path
|
||||
),
|
||||
if r.status.is_some_and(|s| s >= 400) {
|
||||
ERROR
|
||||
} else {
|
||||
TEXT
|
||||
},
|
||||
);
|
||||
l.selected = state.selected_request == Some(r.id);
|
||||
l.action = Some(Action::Request(r.id));
|
||||
lines.push(l);
|
||||
}
|
||||
if lines.is_empty() {
|
||||
lines.push(Line::new(
|
||||
"No matching requests. Listening to Tessera's local API.",
|
||||
MUTED,
|
||||
));
|
||||
}
|
||||
p.lines(
|
||||
Rect::new(x + 1.0, 92.0, w - 2.0, divider - 92.0),
|
||||
Region::Requests,
|
||||
&lines,
|
||||
state,
|
||||
false,
|
||||
);
|
||||
p.split(Rect::new(x + 1.0, divider, w - 2.0, 1.0), Split::Network);
|
||||
let selected = records
|
||||
.iter()
|
||||
.find(|r| Some(r.id) == state.selected_request);
|
||||
let mut details = Vec::new();
|
||||
if let Some(r) = selected {
|
||||
request_details(&mut details, r);
|
||||
} else {
|
||||
multiline(
|
||||
&mut details,
|
||||
"Select a request to inspect headers and bodies.\n\nCapture scope: 127.0.0.1:4737 in this Tessera process.\n80 most recent requests; body previews up to 32 KiB.\nOnly Content-Type and Content-Length request headers are recorded.\nExternal browser / OS traffic is not captured.",
|
||||
MUTED,
|
||||
);
|
||||
}
|
||||
p.lines(
|
||||
Rect::new(x + 1.0, divider + 5.0, w - 2.0, bottom - divider - 5.0),
|
||||
Region::Details,
|
||||
&details,
|
||||
state,
|
||||
false,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
use super::*;
|
||||
|
||||
pub(super) struct Painter {
|
||||
pub(super) ui: Ui<Action>,
|
||||
pub(super) layout: DeveloperToolsLayout,
|
||||
}
|
||||
impl Painter {
|
||||
pub(super) fn fill(&mut self, r: Rect, c: Color) {
|
||||
self.ui.fill(r, c);
|
||||
}
|
||||
pub(super) fn label(&mut self, x: f32, y: f32, value: &str, c: Color, code: bool) {
|
||||
self.ui.label(x, y, value, c, code);
|
||||
}
|
||||
pub(super) fn button(&mut self, r: Rect, label: &str, action: Action, active: bool) {
|
||||
let role = match action {
|
||||
Action::Follow | Action::Snapshot | Action::Cloud => Role::Toggle,
|
||||
_ => Role::Button,
|
||||
};
|
||||
self.ui
|
||||
.button(r, action, Button::new(label).selected(active).role(role));
|
||||
}
|
||||
pub(super) fn enabled_button(&mut self, r: Rect, label: &str, action: Action, enabled: bool) {
|
||||
self.ui
|
||||
.button(r, action, Button::new(label).enabled(enabled));
|
||||
}
|
||||
pub(super) fn split(&mut self, r: Rect, kind: Split) {
|
||||
// Paint and register splitters after all pane content, in one overlay pass.
|
||||
self.layout.splits.push((r, kind));
|
||||
}
|
||||
pub(super) fn lines(
|
||||
&mut self,
|
||||
area: Rect,
|
||||
region: Region,
|
||||
lines: &[Line],
|
||||
state: &mut DeveloperToolsState,
|
||||
follow: bool,
|
||||
) {
|
||||
let content_h = lines.len() as f32 * ROW + 12.0;
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut hash = std::collections::hash_map::DefaultHasher::new();
|
||||
for line in lines {
|
||||
line.value.hash(&mut hash);
|
||||
}
|
||||
let fingerprint = hash.finish();
|
||||
let cache = &mut state.widths[region as usize];
|
||||
let content_w = match *cache {
|
||||
Some((previous, width)) if previous == fingerprint => width,
|
||||
_ => {
|
||||
let width = lines
|
||||
.iter()
|
||||
.map(|line| text::measure(&line.value, TextStyle::code()).width + 20.0)
|
||||
.fold(0.0_f32, f32::max);
|
||||
*cache = Some((fingerprint, width));
|
||||
width
|
||||
}
|
||||
};
|
||||
let s = &mut state.scroll[region as usize];
|
||||
s.y = if follow {
|
||||
(content_h - area.height + 12.0).max(0.0)
|
||||
} else {
|
||||
s.y.clamp(0.0, (content_h - area.height + 12.0).max(0.0))
|
||||
};
|
||||
s.x = s.x.clamp(0.0, (content_w - area.width + 12.0).max(0.0));
|
||||
self.ui.list.push(DrawCommand::PushClip(Rect::new(
|
||||
area.x,
|
||||
area.y,
|
||||
(area.width - 9.0).max(0.0),
|
||||
(area.height - 9.0).max(0.0),
|
||||
)));
|
||||
if region == Region::Source {
|
||||
let values = lines
|
||||
.iter()
|
||||
.map(|line| line.value.chars().skip(7).collect::<String>())
|
||||
.collect::<Vec<_>>();
|
||||
state.source_selection.set_lines(&values);
|
||||
self.layout.source = Some((
|
||||
area,
|
||||
Point::new(
|
||||
area.x + 8.0 - s.x + text::measure(" ", TextStyle::code()).width,
|
||||
area.y + 5.0 - s.y,
|
||||
),
|
||||
values,
|
||||
));
|
||||
}
|
||||
let first = (s.y / ROW).floor() as usize;
|
||||
let count = (area.height / ROW).ceil() as usize + 2;
|
||||
for (i, line) in lines.iter().enumerate().skip(first).take(count) {
|
||||
let y = area.y + 5.0 + i as f32 * ROW - s.y;
|
||||
if let Some(divider) = line.divider {
|
||||
self.fill(
|
||||
Rect::new(area.x, y, area.width, 1.0),
|
||||
match divider {
|
||||
Divider::Accent => self.ui.theme.accent,
|
||||
Divider::Neutral => BORDER,
|
||||
},
|
||||
);
|
||||
}
|
||||
let paint = Rect::new(area.x, y, area.width, ROW);
|
||||
if let Some(action) = &line.action {
|
||||
let top = y.max(area.y);
|
||||
let bottom = (y + ROW).min(area.y + area.height - 9.0);
|
||||
if bottom > top {
|
||||
self.ui.row(
|
||||
paint,
|
||||
Rect::new(area.x, top, area.width - 9.0, bottom - top),
|
||||
action.clone(),
|
||||
line.selected,
|
||||
);
|
||||
}
|
||||
} else if line.selected {
|
||||
self.fill(paint, self.ui.theme.soft());
|
||||
}
|
||||
if region == Region::Source
|
||||
&& let Some((_, origin, values)) = &self.layout.source
|
||||
&& let Some((left, right)) = state.source_selection.span(
|
||||
i,
|
||||
state.selection && state.focus == Some(Focus::Source),
|
||||
values,
|
||||
)
|
||||
{
|
||||
self.fill(
|
||||
Rect::new(origin.x + left, y, right - left, ROW),
|
||||
self.ui.theme.hover(),
|
||||
);
|
||||
}
|
||||
self.label(area.x + 8.0 - s.x, y, &line.value, line.color, true);
|
||||
}
|
||||
self.ui.list.push(DrawCommand::PopClip);
|
||||
self.layout
|
||||
.regions
|
||||
.push((area, region, content_w, content_h));
|
||||
self.scrollbar(area, region, false, s.y, content_h);
|
||||
self.scrollbar(area, region, true, s.x, content_w);
|
||||
}
|
||||
pub(super) fn scrollbar(
|
||||
&mut self,
|
||||
r: Rect,
|
||||
region: Region,
|
||||
h: bool,
|
||||
offset: f32,
|
||||
content: f32,
|
||||
) {
|
||||
let viewport = if h { r.width } else { r.height } - 10.0;
|
||||
if content <= viewport || viewport <= 0.0 {
|
||||
return;
|
||||
}
|
||||
let length = (viewport * viewport / content).max(24.0).min(viewport);
|
||||
let travel = (viewport - length).max(1.0);
|
||||
let maximum = (content - viewport).max(1.0);
|
||||
let position = (offset / maximum).clamp(0.0, 1.0) * travel;
|
||||
let track = if h {
|
||||
Rect::new(r.x, r.y + r.height - 8.0, viewport, 8.0)
|
||||
} else {
|
||||
Rect::new(r.x + r.width - 8.0, r.y, 8.0, viewport)
|
||||
};
|
||||
self.fill(track, BAR);
|
||||
let thumb = if h {
|
||||
Rect::new(track.x + position, track.y + 1.0, length, 6.0)
|
||||
} else {
|
||||
Rect::new(track.x + 1.0, track.y + position, 6.0, length)
|
||||
};
|
||||
self.ui.scrollbar(
|
||||
thumb,
|
||||
WidgetId::new(("scroll", region, h)),
|
||||
if h { Axis::Horizontal } else { Axis::Vertical },
|
||||
);
|
||||
self.layout
|
||||
.thumbs
|
||||
.push((thumb, region, h, maximum / travel));
|
||||
}
|
||||
}
|
||||
#[derive(Clone)]
|
||||
pub(super) struct Line {
|
||||
pub(super) value: String,
|
||||
pub(super) color: Color,
|
||||
pub(super) divider: Option<Divider>,
|
||||
pub(super) selected: bool,
|
||||
pub(super) action: Option<Action>,
|
||||
}
|
||||
#[derive(Clone, Copy)]
|
||||
pub(super) enum Divider {
|
||||
Accent,
|
||||
Neutral,
|
||||
}
|
||||
impl Line {
|
||||
pub(super) fn new(value: impl Into<String>, color: Color) -> Self {
|
||||
Self {
|
||||
value: value.into(),
|
||||
color,
|
||||
divider: None,
|
||||
selected: false,
|
||||
action: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
pub(super) fn multiline(out: &mut Vec<Line>, value: &str, color: Color) {
|
||||
out.extend(
|
||||
value
|
||||
.replace('\t', " ")
|
||||
.split('\n')
|
||||
.map(|s| Line::new(s, color)),
|
||||
);
|
||||
}
|
||||
pub(super) fn console_result(out: &mut Vec<Line>, body: &str, color: Color) {
|
||||
if let Ok(value) = serde_json::from_str::<serde_json::Value>(body) {
|
||||
if value["execution"] == "repl" {
|
||||
out.push(Line::new(
|
||||
if value["rendered"] == true {
|
||||
format!(
|
||||
"Rendered revision {} · session {}",
|
||||
value["revision"], value["session"]
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"Session {} · retained in memory; page unchanged",
|
||||
value["session"]
|
||||
)
|
||||
},
|
||||
MUTED,
|
||||
));
|
||||
}
|
||||
if value["session_reset"] == true {
|
||||
multiline(
|
||||
out,
|
||||
value["message"]
|
||||
.as_str()
|
||||
.or(value["error"].as_str())
|
||||
.unwrap_or("Session reset"),
|
||||
color,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if let Some(result) = value["value"].as_str() {
|
||||
multiline(out, result, color);
|
||||
return;
|
||||
}
|
||||
if let Some(diagnostics) = value["diagnostics"].as_array()
|
||||
&& !diagnostics.is_empty()
|
||||
{
|
||||
for d in diagnostics {
|
||||
if let Some(line) = d["line"].as_u64() {
|
||||
out.push(Line::new(
|
||||
format!("Error · line {line}, column {}", d["column"]),
|
||||
color,
|
||||
));
|
||||
}
|
||||
multiline(
|
||||
out,
|
||||
d["message"].as_str().unwrap_or("Execution failed"),
|
||||
color,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if let Some(error) = value["error"].as_str() {
|
||||
multiline(out, error, color);
|
||||
return;
|
||||
}
|
||||
if let Some(revision) = value["revision"].as_u64() {
|
||||
out.push(Line::new(format!("Committed revision {revision}"), color));
|
||||
return;
|
||||
}
|
||||
}
|
||||
multiline(out, body, color);
|
||||
}
|
||||
pub(super) fn flatten<'a>(node: &'a CodeNode, depth: usize, out: &mut Vec<(usize, &'a CodeNode)>) {
|
||||
out.push((depth, node));
|
||||
for child in &node.children {
|
||||
flatten(child, depth + 1, out);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn request_details(out: &mut Vec<Line>, r: &Exchange) {
|
||||
multiline(
|
||||
out,
|
||||
&format!(
|
||||
"#{} {} {}\n{} · {} · {} ms\nStatus: {}",
|
||||
r.id,
|
||||
r.method,
|
||||
r.path,
|
||||
r.timestamp,
|
||||
r.origin,
|
||||
r.elapsed_ms,
|
||||
r.status.map_or("pending".into(), |s| s.to_string())
|
||||
),
|
||||
TEXT,
|
||||
);
|
||||
for (title, body) in [
|
||||
("REQUEST HEADERS", &r.request_headers),
|
||||
("REQUEST BODY", &r.request),
|
||||
("RESPONSE HEADERS", &r.response_headers),
|
||||
("RESPONSE BODY", &r.response),
|
||||
] {
|
||||
out.push(Line::new("", MUTED));
|
||||
let mut l = Line::new(title, MUTED);
|
||||
l.divider = Some(Divider::Accent);
|
||||
out.push(l);
|
||||
multiline(
|
||||
out,
|
||||
if body.is_empty() { "(empty)" } else { body },
|
||||
if title == "RESPONSE BODY" && r.status.is_some_and(|s| s >= 400) {
|
||||
ERROR
|
||||
} else {
|
||||
TEXT
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
//! Read-only, grapheme-aware source selection, independent of console editing.
|
||||
use crate::{
|
||||
geometry::Point,
|
||||
text::{self, TextStyle},
|
||||
};
|
||||
use unicode_segmentation::UnicodeSegmentation;
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub(super) struct SourceSelection {
|
||||
lines: Vec<String>,
|
||||
anchor: (usize, usize),
|
||||
end: (usize, usize),
|
||||
pub dragging: bool,
|
||||
}
|
||||
impl SourceSelection {
|
||||
pub fn set_lines(&mut self, lines: &[String]) {
|
||||
if self.lines != lines {
|
||||
self.lines = lines.to_vec();
|
||||
self.anchor = (0, 0);
|
||||
self.end = (0, 0);
|
||||
self.dragging = false;
|
||||
}
|
||||
}
|
||||
fn locate(p: Point, origin: Point, lines: &[String]) -> (usize, usize) {
|
||||
let row = (((p.y - origin.y) / super::ROW).floor().max(0.0) as usize)
|
||||
.min(lines.len().saturating_sub(1));
|
||||
let Some(line) = lines.get(row) else {
|
||||
return (0, 0);
|
||||
};
|
||||
let x = (p.x - origin.x).max(0.0);
|
||||
let mut byte = line.len();
|
||||
for (i, g) in line.grapheme_indices(true) {
|
||||
let left = text::measure(&line[..i], TextStyle::code()).width;
|
||||
let right = text::measure(&line[..i + g.len()], TextStyle::code()).width;
|
||||
if x < (left + right) * 0.5 {
|
||||
byte = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
(row, byte)
|
||||
}
|
||||
pub fn begin(&mut self, p: Point, origin: Point, lines: &[String]) {
|
||||
self.anchor = Self::locate(p, origin, lines);
|
||||
self.end = self.anchor;
|
||||
self.dragging = true;
|
||||
}
|
||||
pub fn extend(&mut self, p: Point, origin: Point, lines: &[String]) {
|
||||
self.end = Self::locate(p, origin, lines);
|
||||
}
|
||||
pub fn navigate(
|
||||
&mut self,
|
||||
key: &winit::keyboard::NamedKey,
|
||||
shift: bool,
|
||||
command: bool,
|
||||
) -> Option<usize> {
|
||||
use winit::keyboard::NamedKey::*;
|
||||
let (mut row, mut byte) = self.end;
|
||||
let line = self.lines.get(row)?;
|
||||
match key {
|
||||
ArrowLeft => {
|
||||
if byte > 0 {
|
||||
byte = line[..byte]
|
||||
.grapheme_indices(true)
|
||||
.next_back()
|
||||
.map_or(0, |(i, _)| i);
|
||||
} else if row > 0 {
|
||||
row -= 1;
|
||||
byte = self.lines[row].len();
|
||||
}
|
||||
}
|
||||
ArrowRight => {
|
||||
if byte < line.len() {
|
||||
byte += line[byte..].graphemes(true).next().map_or(0, str::len);
|
||||
} else if row + 1 < self.lines.len() {
|
||||
row += 1;
|
||||
byte = 0;
|
||||
}
|
||||
}
|
||||
ArrowUp | ArrowDown => {
|
||||
let col = line[..byte].graphemes(true).count();
|
||||
row = if *key == ArrowUp {
|
||||
row.saturating_sub(1)
|
||||
} else {
|
||||
(row + 1).min(self.lines.len() - 1)
|
||||
};
|
||||
byte = self.lines[row]
|
||||
.graphemes(true)
|
||||
.take(col)
|
||||
.map(str::len)
|
||||
.sum();
|
||||
}
|
||||
Home => {
|
||||
if command {
|
||||
row = 0;
|
||||
}
|
||||
byte = 0;
|
||||
}
|
||||
End => {
|
||||
if command {
|
||||
row = self.lines.len() - 1;
|
||||
}
|
||||
byte = self.lines[row].len();
|
||||
}
|
||||
_ => return None,
|
||||
}
|
||||
self.end = (row, byte);
|
||||
if !shift {
|
||||
self.anchor = self.end;
|
||||
}
|
||||
Some(row)
|
||||
}
|
||||
fn range(&self, all: bool) -> ((usize, usize), (usize, usize)) {
|
||||
if all {
|
||||
return (
|
||||
(0, 0),
|
||||
(
|
||||
self.lines.len().saturating_sub(1),
|
||||
self.lines.last().map_or(0, String::len),
|
||||
),
|
||||
);
|
||||
}
|
||||
(self.anchor.min(self.end), self.anchor.max(self.end))
|
||||
}
|
||||
pub fn text(&self, all: bool) -> String {
|
||||
let (a, b) = self.range(all);
|
||||
self.lines
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(i, _)| *i >= a.0 && *i <= b.0)
|
||||
.map(|(i, line)| {
|
||||
let start = if i == a.0 { a.1 } else { 0 };
|
||||
let end = if i == b.0 { b.1 } else { line.len() };
|
||||
line.get(start..end).unwrap_or("").to_owned()
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
pub fn span(&self, row: usize, all: bool, lines: &[String]) -> Option<(f32, f32)> {
|
||||
let (a, b) = self.range(all);
|
||||
if row < a.0 || row > b.0 || a == b {
|
||||
return None;
|
||||
}
|
||||
let line = lines.get(row)?;
|
||||
let start = if row == a.0 { a.1 } else { 0 };
|
||||
let end = if row == b.0 { b.1 } else { line.len() };
|
||||
Some((
|
||||
text::measure(line.get(..start)?, TextStyle::code()).width,
|
||||
text::measure(line.get(..end)?, TextStyle::code()).width,
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
use super::*;
|
||||
|
||||
pub(super) fn build(p: &mut Painter, state: &mut DeveloperToolsState, viewport: Rect) {
|
||||
let x = viewport.x;
|
||||
let w = viewport.width;
|
||||
|
||||
p.label(x + 24.0, 62.0, "Appearance", TEXT, false);
|
||||
p.label(
|
||||
x + 24.0,
|
||||
90.0,
|
||||
"Accent color · tabs, focus and execution separators",
|
||||
MUTED,
|
||||
false,
|
||||
);
|
||||
for (i, color) in PALETTE.iter().enumerate() {
|
||||
let r = Rect::new(x + 24.0 + i as f32 * 44.0, 122.0, 32.0, 32.0);
|
||||
p.ui.swatch(r, Action::Theme(i), *color, *color == state.accent);
|
||||
}
|
||||
p.label(
|
||||
x + 24.0,
|
||||
176.0,
|
||||
"Custom hex · Enter to apply (bright colors adjusted)",
|
||||
MUTED,
|
||||
false,
|
||||
);
|
||||
let custom = Rect::new(x + 24.0, 204.0, 200.0, 32.0);
|
||||
p.ui.text_field(
|
||||
custom,
|
||||
Action::Focus(Focus::Custom),
|
||||
state.focus == Some(Focus::Custom),
|
||||
);
|
||||
p.label(
|
||||
custom.x + 10.0,
|
||||
custom.y + 6.0,
|
||||
&format!(
|
||||
"{}{}",
|
||||
state.custom,
|
||||
if state.focus == Some(Focus::Custom) {
|
||||
"│"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
),
|
||||
TEXT,
|
||||
true,
|
||||
);
|
||||
|
||||
if !state.input_error.is_empty() {
|
||||
p.label(x + 24.0, 245.0, &state.input_error, ERROR, false);
|
||||
}
|
||||
p.fill(Rect::new(x + 24.0, 285.0, w - 48.0, 1.0), BORDER);
|
||||
p.label(x + 24.0, 305.0, "Theme preview", TEXT, false);
|
||||
p.fill(Rect::new(x + 24.0, 340.0, w - 48.0, 2.0), state.accent);
|
||||
p.label(x + 32.0, 353.0, "› \"Hello\"", TEXT, true);
|
||||
p.label(x + 32.0, 380.0, "‹ \"Hello\"", OK, true);
|
||||
p.label(
|
||||
x + 24.0,
|
||||
430.0,
|
||||
"Settings last for this window session. Source is not persisted.",
|
||||
MUTED,
|
||||
false,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
use super::*;
|
||||
|
||||
pub(super) fn build(
|
||||
p: &mut Painter,
|
||||
state: &mut DeveloperToolsState,
|
||||
doc: &RenderDocument,
|
||||
viewport: Rect,
|
||||
) {
|
||||
let x = viewport.x;
|
||||
let w = viewport.width;
|
||||
let width = viewport.x + viewport.width;
|
||||
let bottom = viewport.height - 22.0;
|
||||
|
||||
p.fill(Rect::new(x, 32.0, w, 30.0), BAR);
|
||||
p.button(
|
||||
Rect::new(x + 3.0, 32.0, 139.0, 29.0),
|
||||
"Current snapshot",
|
||||
Action::Snapshot,
|
||||
!state.cloud,
|
||||
);
|
||||
p.button(
|
||||
Rect::new(x + 145.0, 32.0, 107.0, 29.0),
|
||||
"Cloud files",
|
||||
Action::Cloud,
|
||||
state.cloud,
|
||||
);
|
||||
if state.cloud {
|
||||
p.button(
|
||||
Rect::new(width - 92.0, 32.0, 88.0, 29.0),
|
||||
"Refresh",
|
||||
Action::CloudRefresh,
|
||||
false,
|
||||
);
|
||||
let tree = state.tree_width.clamp(120.0, (w - 180.0).max(120.0));
|
||||
let code_x = x + tree + 2.0;
|
||||
p.label(x + 10.0, 69.0, "CLOUD FILES", MUTED, false);
|
||||
let file_lines = state
|
||||
.cloud_sources
|
||||
.files
|
||||
.iter()
|
||||
.map(|file| {
|
||||
let mut l = Line::new(&file.path, TEXT);
|
||||
l.action = Some(Action::CloudOpen(file.id.clone()));
|
||||
l
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
p.lines(
|
||||
Rect::new(x + 1.0, 92.0, tree - 1.0, bottom - 92.0),
|
||||
Region::Tree,
|
||||
&file_lines,
|
||||
state,
|
||||
false,
|
||||
);
|
||||
p.split(Rect::new(x + tree, 63.0, 1.0, bottom - 63.0), Split::Tree);
|
||||
let mut source_lines = Vec::new();
|
||||
if let Some(source) = &state.cloud_sources.opened {
|
||||
p.label(code_x + 10.0, 69.0, &source.path, MUTED, false);
|
||||
for (i, line) in source.code.split('\n').enumerate() {
|
||||
source_lines.push(Line::new(format!("{:>5} {}", i + 1, line), TEXT));
|
||||
}
|
||||
} else {
|
||||
multiline(
|
||||
&mut source_lines,
|
||||
"No cloud file open\n\nNo provider connected.\nCloud files will open here as full source.\nCurrent snapshot contains your last successful script.",
|
||||
MUTED,
|
||||
);
|
||||
}
|
||||
if !state.cloud_sources.error.is_empty() {
|
||||
source_lines.push(Line::new(&state.cloud_sources.error, ERROR));
|
||||
}
|
||||
p.lines(
|
||||
Rect::new(code_x, 92.0, width - code_x, bottom - 92.0),
|
||||
Region::Source,
|
||||
&source_lines,
|
||||
state,
|
||||
false,
|
||||
);
|
||||
} else {
|
||||
let tree = state.tree_width.clamp(120.0, (w - 180.0).max(120.0));
|
||||
let code_x = x + tree + 2.0;
|
||||
p.label(x + 10.0, 69.0, "ASTER AST", MUTED, false);
|
||||
p.label(
|
||||
code_x + 10.0,
|
||||
69.0,
|
||||
&format!(
|
||||
"{} · {} lines · read only",
|
||||
if doc
|
||||
.root
|
||||
.as_ref()
|
||||
.is_some_and(|root| root.kind == "repl-session")
|
||||
{
|
||||
"Console transcript"
|
||||
} else {
|
||||
"app.main.endor"
|
||||
},
|
||||
doc.source.split('\n').count()
|
||||
),
|
||||
MUTED,
|
||||
false,
|
||||
);
|
||||
let mut tree_lines = Vec::new();
|
||||
if let Some(root) = &doc.root {
|
||||
let mut flat = Vec::new();
|
||||
flatten(root, 0, &mut flat);
|
||||
for (depth, n) in flat {
|
||||
let mut l = Line::new(format!("{}{} {}", " ".repeat(depth), n.kind, n.name), TEXT);
|
||||
l.selected = state.selected_node_id.as_deref() == Some(&n.id);
|
||||
l.action = Some(Action::Node(n.id.clone(), n.start_line));
|
||||
tree_lines.push(l);
|
||||
}
|
||||
} else {
|
||||
tree_lines.push(Line::new("No submitted source", MUTED));
|
||||
}
|
||||
p.lines(
|
||||
Rect::new(x + 1.0, 92.0, tree - 1.0, bottom - 92.0),
|
||||
Region::Tree,
|
||||
&tree_lines,
|
||||
state,
|
||||
false,
|
||||
);
|
||||
p.split(Rect::new(x + tree, 63.0, 1.0, bottom - 63.0), Split::Tree);
|
||||
let selected = state
|
||||
.selected_node_id
|
||||
.as_deref()
|
||||
.and_then(|id| doc.node(id));
|
||||
let code_lines = doc
|
||||
.source
|
||||
.replace("\r\n", "\n")
|
||||
.split('\n')
|
||||
.enumerate()
|
||||
.map(|(i, s)| {
|
||||
let mut l = Line::new(format!("{:>5} {}", i + 1, s.replace('\t', " ")), TEXT);
|
||||
l.selected = selected.is_some_and(|n| i + 1 >= n.start_line && i < n.end_line);
|
||||
l
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
p.lines(
|
||||
Rect::new(code_x, 92.0, width - code_x, bottom - 92.0),
|
||||
Region::Source,
|
||||
&code_lines,
|
||||
state,
|
||||
false,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user