Preserve raw include diagnostic provenance and derive bands from the existing shaped editor. Retain deferred tooltip paragraphs through rendering. Include source, regression, failure and native-trial evidence; corrected native acceptance remains pending.
1079 lines
39 KiB
Rust
1079 lines
39 KiB
Rust
//! Compact Text workspace presentation. Visibility is explicit controller state;
|
|
//! rebuilding diagnostics or query results must never change editor geometry.
|
|
use crate::app::text_workspace::{Command, DrawerTab, Event};
|
|
use crate::{
|
|
app::{App, Message},
|
|
style,
|
|
};
|
|
use iced::{
|
|
Element, Fill, Font, Length,
|
|
widget::{self, button, column, container, row, scrollable, text, text_input},
|
|
};
|
|
use std::{cell::Cell, rc::Rc};
|
|
|
|
pub(crate) const STRIP_HEIGHT: f32 = 42.;
|
|
pub(crate) const FIND_HEIGHT: f32 = 36.;
|
|
pub(crate) const DRAWER_HEIGHT: f32 = 180.;
|
|
pub(crate) const STATUS_HEIGHT: f32 = 22.;
|
|
const EDITOR_PADDING: f32 = 16.;
|
|
|
|
/// The caller supplies one persistent diagnostic Guard, including when hidden
|
|
/// (None publication and empty child). References is empty while hidden; its
|
|
/// grouping and scroll restoration are controller-owned.
|
|
pub(super) fn view<'a>(
|
|
app: &'a App,
|
|
references: Element<'a, Message>,
|
|
problems: Element<'a, Message>,
|
|
) -> Element<'a, Message> {
|
|
let tab = app.text_workspace.drawer();
|
|
let measured_drawer = Rc::new(Cell::new(DRAWER_HEIGHT));
|
|
let strip = row![
|
|
container(
|
|
text(app.source_workspace.active.clone())
|
|
.font(Font::MONOSPACE)
|
|
.size(12)
|
|
.wrapping(widget::text::Wrapping::None)
|
|
)
|
|
.width(Fill)
|
|
.clip(true),
|
|
compact_action("Find", app.text_command_message(Command::Find)),
|
|
crate::context_menu::Area::left(
|
|
button(text("Actions ▾").size(12))
|
|
.padding([4, 8])
|
|
.style(style::nav)
|
|
.on_press(app.text_menu_message(iced::Point::ORIGIN)),
|
|
|anchor| app.text_menu_message(anchor),
|
|
),
|
|
compact_action("Apply all", Message::ApplySource),
|
|
]
|
|
.spacing(6)
|
|
.align_y(iced::Center);
|
|
let find: Element<'a, Message> = if app.source_navigation.open {
|
|
find_bar(app)
|
|
} else {
|
|
widget::Space::new().into()
|
|
};
|
|
let editor = widget::text_editor(&app.source_editor)
|
|
.id(app.source_editor_id())
|
|
.on_action(Message::SourceEdit)
|
|
.key_binding({
|
|
let context = app.source_symbol_context();
|
|
let rename = app.source_rename_begin();
|
|
move |press| super::source_binding(press, &context, Some(&rename))
|
|
})
|
|
.font(Font::MONOSPACE)
|
|
.size(14)
|
|
.padding(EDITOR_PADDING)
|
|
.height(Fill)
|
|
.highlight_with::<crate::highlight::ArcadiaHighlighter>(
|
|
app.source_snapshot.clone(),
|
|
crate::highlight::format,
|
|
);
|
|
let editor = crate::source_annotations::Decorations::new(
|
|
editor,
|
|
&app.source_editor,
|
|
app.source_annotation_snapshot(),
|
|
iced::Padding::new(EDITOR_PADDING),
|
|
app.source_navigation_available(),
|
|
);
|
|
let editor = widget::sensor(editor)
|
|
.key((
|
|
app.source_workspace.active.clone(),
|
|
app.source_reveal_ticket(),
|
|
))
|
|
.on_show({
|
|
let ticket = app.source_reveal_ticket();
|
|
move |_| Message::SourceViewport(ticket)
|
|
})
|
|
.on_resize({
|
|
let ticket = app.source_reveal_ticket();
|
|
move |_| Message::SourceViewport(ticket)
|
|
});
|
|
let (errors, warnings) = diagnostic_counts(app);
|
|
let summary: Element<'a, Message> = match tab {
|
|
Some(DrawerTab::References) => reference_summary(app),
|
|
Some(DrawerTab::Problems) => text(format!(
|
|
"{errors} source errors · {warnings} warnings · choose a location for details"
|
|
))
|
|
.size(12)
|
|
.wrapping(widget::text::Wrapping::None)
|
|
.into(),
|
|
Some(DrawerTab::Status) => text("Source adoption, recovery and text/model undo")
|
|
.size(12)
|
|
.wrapping(widget::text::Wrapping::None)
|
|
.into(),
|
|
None => widget::Space::new().into(),
|
|
};
|
|
let chrome: Element<'a, Message> = if tab.is_some() {
|
|
let mut tabs = row![].spacing(4).align_y(iced::Center);
|
|
for (destination, title) in [
|
|
(DrawerTab::References, "References"),
|
|
(DrawerTab::Problems, "Problems"),
|
|
(DrawerTab::Status, "Status"),
|
|
] {
|
|
tabs = tabs.push(
|
|
button(text(title).size(12))
|
|
.padding([4, 8])
|
|
.style(if tab == Some(destination) {
|
|
style::selected
|
|
} else {
|
|
style::nav
|
|
})
|
|
.on_press(Message::TextWorkspace(Event::ShowDrawer(destination))),
|
|
);
|
|
}
|
|
tabs.push(widget::Space::new().width(Fill))
|
|
.push(resize_button("Smaller", measured_drawer.clone(), -60.))
|
|
.push(resize_button("Larger", measured_drawer.clone(), 60.))
|
|
.push(compact_action(
|
|
"Hide",
|
|
if tab == Some(DrawerTab::References) {
|
|
app.source_symbol_message(crate::source_navigation::SymbolEvent::Close)
|
|
} else {
|
|
Message::TextWorkspace(Event::HideDrawer)
|
|
},
|
|
))
|
|
.into()
|
|
} else {
|
|
widget::Space::new().into()
|
|
};
|
|
let status: Element<'a, Message> = if tab == Some(DrawerTab::Status) {
|
|
status_body(app)
|
|
} else {
|
|
widget::Space::new().into()
|
|
};
|
|
let body = widget::Column::from_vec(vec![
|
|
container(references)
|
|
.width(Fill)
|
|
.height(if tab == Some(DrawerTab::References) {
|
|
Length::Fill
|
|
} else {
|
|
Length::Fixed(0.)
|
|
})
|
|
.clip(true)
|
|
.into(),
|
|
tab_slot(
|
|
problems,
|
|
tab == Some(DrawerTab::Problems),
|
|
"text-problems-body",
|
|
),
|
|
tab_slot(status, tab == Some(DrawerTab::Status), "text-status-body"),
|
|
])
|
|
.width(Fill)
|
|
.height(Fill);
|
|
let drawer = widget::Column::from_vec(vec![
|
|
container(chrome).width(Fill).height(32).clip(true).into(),
|
|
container(summary).width(Fill).height(24).clip(true).into(),
|
|
body.into(),
|
|
])
|
|
.width(Fill)
|
|
.height(Fill);
|
|
let mut states = Vec::new();
|
|
if app.pending_recovery.is_some() {
|
|
states.push("Recovery");
|
|
}
|
|
if !app.source_workspace.applied {
|
|
states.push("Repair needed");
|
|
}
|
|
if app.source_workspace.detached_dirty() {
|
|
states.push("Detached draft");
|
|
}
|
|
if app.source_dirty() {
|
|
states.push("Drafts pending");
|
|
}
|
|
let state = match states.as_slice() {
|
|
[] => "Model in sync".to_owned(),
|
|
[only] => (*only).to_owned(),
|
|
[first, rest @ ..] => format!("{first} +{} conditions", rest.len()),
|
|
};
|
|
let source_status = button(
|
|
text(format!(
|
|
"{errors} errors · {warnings} warnings · {state} · Status…"
|
|
))
|
|
.size(12),
|
|
)
|
|
.padding([2, 8])
|
|
.width(Fill)
|
|
.height(STATUS_HEIGHT)
|
|
.style(style::nav)
|
|
.on_press(Message::TextWorkspace(Event::ShowDrawer(DrawerTab::Status)));
|
|
layout(
|
|
container(strip)
|
|
.width(Fill)
|
|
.height(Fill)
|
|
.padding([4, 8])
|
|
.style(style::panel)
|
|
.into(),
|
|
find,
|
|
editor.into(),
|
|
container(drawer).style(style::panel).into(),
|
|
source_status.into(),
|
|
LayoutSettings {
|
|
find_open: app.source_navigation.open,
|
|
drawer_height: tab.map(|_| app.text_workspace.drawer_height()),
|
|
measured_drawer,
|
|
},
|
|
)
|
|
}
|
|
|
|
fn resize_button(
|
|
label: &'static str,
|
|
height: Rc<Cell<f32>>,
|
|
delta: f32,
|
|
) -> Element<'static, Message> {
|
|
widget::tooltip(
|
|
button(text(label).size(12))
|
|
.padding([4, 8])
|
|
.style(style::nav)
|
|
.on_press_with(move || {
|
|
Message::TextWorkspace(Event::ResizeDrawer(height.get() + delta))
|
|
}),
|
|
text(if delta < 0. {
|
|
"Smaller drawer · Alt Page Up"
|
|
} else {
|
|
"Larger drawer · Alt Page Down"
|
|
})
|
|
.size(12),
|
|
widget::tooltip::Position::Top,
|
|
)
|
|
.into()
|
|
}
|
|
|
|
fn tab_slot<'a>(
|
|
body: Element<'a, Message>,
|
|
active: bool,
|
|
id: &'static str,
|
|
) -> Element<'a, Message> {
|
|
container(
|
|
scrollable(
|
|
container(body)
|
|
.width(Fill)
|
|
.padding(iced::Padding::ZERO.right(14)),
|
|
)
|
|
.id(id)
|
|
.height(Fill)
|
|
.width(Fill),
|
|
)
|
|
.width(Fill)
|
|
.height(if active {
|
|
Length::Fill
|
|
} else {
|
|
Length::Fixed(0.)
|
|
})
|
|
.clip(true)
|
|
.into()
|
|
}
|
|
|
|
fn find_bar(app: &App) -> Element<'_, Message> {
|
|
use crate::source_navigation::Event as Find;
|
|
container(
|
|
row![
|
|
text_input("Find literal text…", &app.source_navigation.query)
|
|
.id("source-find")
|
|
.on_input(|value| Message::SourceNavigation(Find::Query(value)))
|
|
.on_submit(Message::SourceNavigation(Find::Next(false)))
|
|
.size(12)
|
|
.padding(5)
|
|
.width(Fill),
|
|
compact_action("Previous", Message::SourceNavigation(Find::Next(true))),
|
|
compact_action("Next", Message::SourceNavigation(Find::Next(false))),
|
|
text(app.source_navigation.label()).size(12),
|
|
text_input("Line", &app.source_navigation.line)
|
|
.id("source-line")
|
|
.on_input(|value| Message::SourceNavigation(Find::Line(value)))
|
|
.on_submit(Message::SourceNavigation(Find::Go))
|
|
.size(12)
|
|
.padding(5)
|
|
.width(58),
|
|
compact_action("Go", Message::SourceNavigation(Find::Go)),
|
|
compact_action("Close", app.source_find_close_message()),
|
|
]
|
|
.spacing(4)
|
|
.align_y(iced::Center),
|
|
)
|
|
.width(Fill)
|
|
.height(Fill)
|
|
.padding([2, 8])
|
|
.into()
|
|
}
|
|
|
|
fn reference_summary<'a, Renderer: iced::advanced::text::Renderer + 'a>(
|
|
app: &'a App,
|
|
) -> Element<'a, Message, iced::Theme, Renderer> {
|
|
let Some(results) = &app.source_symbols.results else {
|
|
return text(if app.source_symbols.in_flight.is_some() {
|
|
"Finding source symbols…"
|
|
} else {
|
|
"Run References or Definition from Actions or the editor shortcuts."
|
|
})
|
|
.size(12)
|
|
.wrapping(widget::text::Wrapping::None)
|
|
.into();
|
|
};
|
|
let subject = results.alias.as_deref().map_or_else(
|
|
|| "Source symbols".to_owned(),
|
|
|alias| format!("@{}", super::short(alias, 32)),
|
|
);
|
|
let value = format!(
|
|
"{subject} · {} declarations · {} uses",
|
|
results.total_definitions, results.total_references
|
|
);
|
|
let mut flags = Vec::new();
|
|
if results.stamp.snapshot != app.source_key() {
|
|
flags.push("Stale");
|
|
}
|
|
if results.scope.partial() {
|
|
flags.push("Partial");
|
|
}
|
|
if results.total_definitions > 1 {
|
|
flags.push("Collision");
|
|
}
|
|
if results.scope.detached_files > 0 {
|
|
flags.push("Detached");
|
|
}
|
|
if results.scope.unverified_files > 0 {
|
|
flags.push("Unverified");
|
|
}
|
|
let detail = text(value).size(12).wrapping(widget::text::Wrapping::None);
|
|
if flags.is_empty() {
|
|
return detail.into();
|
|
}
|
|
// Exception status owns its width; a long alias can only clip the detail.
|
|
row![
|
|
text(flags.join(" · "))
|
|
.size(12)
|
|
.wrapping(widget::text::Wrapping::None),
|
|
container(detail).width(Fill).clip(true),
|
|
]
|
|
.spacing(8)
|
|
.align_y(iced::Center)
|
|
.width(Fill)
|
|
.into()
|
|
}
|
|
|
|
fn compact_action(label: &str, message: Message) -> widget::Button<'_, Message> {
|
|
button(text(label.to_owned()).size(12))
|
|
.padding([4, 8])
|
|
.style(style::nav)
|
|
.on_press(message)
|
|
}
|
|
|
|
/// Conditions accumulate: recovery must not conceal detached drafts or repair.
|
|
fn status_body(app: &App) -> Element<'_, Message> {
|
|
let workspace = &app.source_workspace;
|
|
let mut body = column![
|
|
text(workspace.active.clone()).font(Font::MONOSPACE).size(12).wrapping(widget::text::Wrapping::WordOrGlyph),
|
|
text("Resize this drawer with Smaller / Larger or Alt Page Up / Alt Page Down.").size(13),
|
|
text("Undo text affects only this file's draft. Undo model restores accepted source files together (100 changes / about 32 MiB). Undo rename restores the accepted rename transaction when available.").size(13),
|
|
row![compact_action("Undo text", app.text_command_message(Command::UndoText)), compact_action("Redo text", app.text_command_message(Command::RedoText))].spacing(6),
|
|
].spacing(8).width(Fill);
|
|
if let Some(path) = &app.pending_recovery {
|
|
let blocked = !workspace.applied && workspace.has_edits();
|
|
body = body.push(text(format!("Interrupted save · {}", path.display())).size(13).wrapping(widget::text::Wrapping::WordOrGlyph))
|
|
.push(text(if blocked {
|
|
"Revert the edited files before recovery. Recovery restores files to their state before the interrupted save, then reopens them."
|
|
} else if workspace.applied {
|
|
"Restore files to their state before the interrupted save. Your current model and editor drafts are kept."
|
|
} else {
|
|
"Restore files to their state before the interrupted save, then reopen them. This rolls back the incomplete save."
|
|
}).size(13))
|
|
.push(button(text("Recover interrupted save").size(12)).padding([4, 8]).on_press_maybe((!blocked && !app.busy).then_some(Message::SourceRecover)));
|
|
}
|
|
if !workspace.applied {
|
|
body = body.push(text("Project needs repair. The previous graphical model remains available. Apply all validates and adopts the loaded reachable files as one model change.").size(13));
|
|
if workspace.has_edits() {
|
|
body = body.push(text("Edited files are retained as drafts. Revert each edited file before recovering an interrupted save; reverting discards that file's edits.").size(13));
|
|
}
|
|
}
|
|
if workspace.detached_dirty() {
|
|
body = body.push(text("A detached file has an unsaved draft. Reattach it or revert that file before saving. Closing a detached buffer never deletes its file.").size(13));
|
|
}
|
|
if app.source_dirty() {
|
|
body = body.push(text("Drafts pending. The graphical views continue to show the accepted model until Apply all succeeds.").size(13));
|
|
}
|
|
body = body.push(
|
|
row![
|
|
compact_action("Apply all", Message::ApplySource),
|
|
compact_action("Revert file", app.text_command_message(Command::RevertFile)),
|
|
compact_action("Save all", Message::Save(false)),
|
|
]
|
|
.spacing(6),
|
|
);
|
|
body.into()
|
|
}
|
|
|
|
fn diagnostic_counts(app: &App) -> (usize, usize) {
|
|
app.source_diagnostics
|
|
.iter()
|
|
.fold((0, 0), |(errors, warnings), issue| {
|
|
match issue.diagnostic.severity {
|
|
oa_core::validation::Severity::Error => (errors + 1, warnings),
|
|
oa_core::validation::Severity::Warning => (errors, warnings + 1),
|
|
}
|
|
})
|
|
}
|
|
|
|
/// All five positions exist on every rebuild. In particular, the editor never
|
|
/// changes siblings or wrapper types when Find, diagnostics or results change.
|
|
pub(crate) fn layout<'a, Renderer: iced::advanced::text::Renderer + 'a>(
|
|
strip: Element<'a, Message, iced::Theme, Renderer>,
|
|
find: Element<'a, Message, iced::Theme, Renderer>,
|
|
editor: Element<'a, Message, iced::Theme, Renderer>,
|
|
drawer: Element<'a, Message, iced::Theme, Renderer>,
|
|
status: Element<'a, Message, iced::Theme, Renderer>,
|
|
settings: LayoutSettings,
|
|
) -> Element<'a, Message, iced::Theme, Renderer> {
|
|
Element::new(WorkspaceLayout {
|
|
children: vec![
|
|
container(strip)
|
|
.id("text-workspace-strip")
|
|
.width(Fill)
|
|
.height(Fill)
|
|
.clip(true)
|
|
.into(),
|
|
container(find)
|
|
.id("text-workspace-find")
|
|
.width(Fill)
|
|
.height(Fill)
|
|
.clip(true)
|
|
.into(),
|
|
container(editor)
|
|
.id("text-workspace-editor")
|
|
.width(Fill)
|
|
.height(Fill)
|
|
.clip(true)
|
|
.into(),
|
|
container(drawer)
|
|
.id("text-workspace-drawer")
|
|
.width(Fill)
|
|
.height(Fill)
|
|
.clip(true)
|
|
.into(),
|
|
container(status)
|
|
.id("text-workspace-status")
|
|
.width(Fill)
|
|
.height(Fill)
|
|
.clip(true)
|
|
.into(),
|
|
],
|
|
settings,
|
|
})
|
|
}
|
|
|
|
pub(crate) struct LayoutSettings {
|
|
pub find_open: bool,
|
|
pub drawer_height: Option<f32>,
|
|
/// Layout publishes the effective height so Smaller immediately shrinks a
|
|
/// previously oversized request after a window resize.
|
|
pub measured_drawer: Rc<Cell<f32>>,
|
|
}
|
|
|
|
fn slot_heights(height: f32, find: bool, requested: Option<f32>) -> [f32; 5] {
|
|
let height = height.max(0.);
|
|
let strip = STRIP_HEIGHT.min(height);
|
|
let status = STATUS_HEIGHT.min(height - strip);
|
|
let find = if find {
|
|
FIND_HEIGHT.min(height - strip - status)
|
|
} else {
|
|
0.
|
|
};
|
|
let available = (height - strip - status - find).max(0.);
|
|
let drawer = requested.map_or(0., |requested| {
|
|
let maximum = (available - 240.).max(DRAWER_HEIGHT).min(available);
|
|
let requested = if requested.is_finite() {
|
|
requested.max(DRAWER_HEIGHT)
|
|
} else {
|
|
DRAWER_HEIGHT
|
|
};
|
|
requested.min(maximum)
|
|
});
|
|
[strip, find, available - drawer, drawer, status]
|
|
}
|
|
|
|
struct WorkspaceLayout<'a, Renderer> {
|
|
children: Vec<Element<'a, Message, iced::Theme, Renderer>>,
|
|
settings: LayoutSettings,
|
|
}
|
|
|
|
impl<Renderer: iced::advanced::text::Renderer>
|
|
iced::advanced::Widget<Message, iced::Theme, Renderer> for WorkspaceLayout<'_, Renderer>
|
|
{
|
|
fn children(&self) -> Vec<iced::advanced::widget::Tree> {
|
|
self.children
|
|
.iter()
|
|
.map(iced::advanced::widget::Tree::new)
|
|
.collect()
|
|
}
|
|
fn diff(&self, tree: &mut iced::advanced::widget::Tree) {
|
|
tree.diff_children(&self.children);
|
|
}
|
|
fn size(&self) -> iced::Size<Length> {
|
|
iced::Size::new(Fill, Fill)
|
|
}
|
|
fn layout(
|
|
&mut self,
|
|
tree: &mut iced::advanced::widget::Tree,
|
|
renderer: &Renderer,
|
|
limits: &iced::advanced::layout::Limits,
|
|
) -> iced::advanced::layout::Node {
|
|
let size = limits.resolve(Fill, Fill, iced::Size::ZERO);
|
|
let heights = slot_heights(
|
|
size.height,
|
|
self.settings.find_open,
|
|
self.settings.drawer_height,
|
|
);
|
|
self.settings.measured_drawer.set(heights[3]);
|
|
let mut y = 0.;
|
|
let nodes = self
|
|
.children
|
|
.iter_mut()
|
|
.zip(&mut tree.children)
|
|
.zip(heights)
|
|
.map(|((child, state), height)| {
|
|
let slot = iced::Size::new(size.width, height);
|
|
let node = child
|
|
.as_widget_mut()
|
|
.layout(
|
|
state,
|
|
renderer,
|
|
&iced::advanced::layout::Limits::new(slot, slot),
|
|
)
|
|
.move_to(iced::Point::new(0., y));
|
|
y += height;
|
|
node
|
|
})
|
|
.collect();
|
|
iced::advanced::layout::Node::with_children(size, nodes)
|
|
}
|
|
fn operate(
|
|
&mut self,
|
|
tree: &mut iced::advanced::widget::Tree,
|
|
layout: iced::advanced::Layout<'_>,
|
|
renderer: &Renderer,
|
|
operation: &mut dyn iced::advanced::widget::Operation,
|
|
) {
|
|
operation.container(None, layout.bounds());
|
|
operation.traverse(&mut |operation| {
|
|
for ((child, state), layout) in self
|
|
.children
|
|
.iter_mut()
|
|
.zip(&mut tree.children)
|
|
.zip(layout.children())
|
|
{
|
|
child
|
|
.as_widget_mut()
|
|
.operate(state, layout, renderer, operation);
|
|
}
|
|
});
|
|
}
|
|
fn update(
|
|
&mut self,
|
|
tree: &mut iced::advanced::widget::Tree,
|
|
event: &iced::Event,
|
|
layout: iced::advanced::Layout<'_>,
|
|
cursor: iced::mouse::Cursor,
|
|
renderer: &Renderer,
|
|
clipboard: &mut dyn iced::advanced::Clipboard,
|
|
shell: &mut iced::advanced::Shell<'_, Message>,
|
|
viewport: &iced::Rectangle,
|
|
) {
|
|
// Local drawer commands. Popup is the persistent outer host and owns
|
|
// keyboard input while a menu is open; closed drawers claim no keys.
|
|
if self.settings.drawer_height.is_some()
|
|
&& let iced::Event::Keyboard(iced::keyboard::Event::KeyPressed {
|
|
key, modifiers, ..
|
|
}) = event
|
|
&& *modifiers == iced::keyboard::Modifiers::ALT
|
|
{
|
|
let delta = match key {
|
|
iced::keyboard::Key::Named(iced::keyboard::key::Named::PageUp) => Some(-60.),
|
|
iced::keyboard::Key::Named(iced::keyboard::key::Named::PageDown) => Some(60.),
|
|
_ => None,
|
|
};
|
|
if let Some(delta) = delta {
|
|
shell.publish(Message::TextWorkspace(Event::ResizeDrawer(
|
|
self.settings.measured_drawer.get() + delta,
|
|
)));
|
|
shell.capture_event();
|
|
return;
|
|
}
|
|
}
|
|
// Preserve delivery to the hidden permanent Guard: it may need to
|
|
// swallow an armed release. Hidden content is emptied by the caller.
|
|
for ((child, state), layout) in self
|
|
.children
|
|
.iter_mut()
|
|
.zip(&mut tree.children)
|
|
.zip(layout.children())
|
|
{
|
|
child.as_widget_mut().update(
|
|
state, event, layout, cursor, renderer, clipboard, shell, viewport,
|
|
);
|
|
}
|
|
}
|
|
fn mouse_interaction(
|
|
&self,
|
|
tree: &iced::advanced::widget::Tree,
|
|
layout: iced::advanced::Layout<'_>,
|
|
cursor: iced::mouse::Cursor,
|
|
viewport: &iced::Rectangle,
|
|
renderer: &Renderer,
|
|
) -> iced::mouse::Interaction {
|
|
self.children
|
|
.iter()
|
|
.zip(&tree.children)
|
|
.zip(layout.children())
|
|
.map(|((child, state), layout)| {
|
|
child
|
|
.as_widget()
|
|
.mouse_interaction(state, layout, cursor, viewport, renderer)
|
|
})
|
|
.max()
|
|
.unwrap_or_default()
|
|
}
|
|
fn draw(
|
|
&self,
|
|
tree: &iced::advanced::widget::Tree,
|
|
renderer: &mut Renderer,
|
|
theme: &iced::Theme,
|
|
style: &iced::advanced::renderer::Style,
|
|
layout: iced::advanced::Layout<'_>,
|
|
cursor: iced::mouse::Cursor,
|
|
viewport: &iced::Rectangle,
|
|
) {
|
|
if let Some(viewport) = layout.bounds().intersection(viewport) {
|
|
for ((child, state), layout) in self
|
|
.children
|
|
.iter()
|
|
.zip(&tree.children)
|
|
.zip(layout.children())
|
|
{
|
|
child
|
|
.as_widget()
|
|
.draw(state, renderer, theme, style, layout, cursor, &viewport);
|
|
}
|
|
}
|
|
}
|
|
fn overlay<'b>(
|
|
&'b mut self,
|
|
tree: &'b mut iced::advanced::widget::Tree,
|
|
layout: iced::advanced::Layout<'b>,
|
|
renderer: &Renderer,
|
|
viewport: &iced::Rectangle,
|
|
translation: iced::Vector,
|
|
) -> Option<iced::advanced::overlay::Element<'b, Message, iced::Theme, Renderer>> {
|
|
iced::advanced::overlay::from_children(
|
|
&mut self.children,
|
|
tree,
|
|
layout,
|
|
renderer,
|
|
viewport,
|
|
translation,
|
|
)
|
|
}
|
|
}
|
|
|
|
#[cfg(all(test, debug_assertions))]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::views::source_tests::FontLayout;
|
|
use iced::widget::column;
|
|
use iced::{
|
|
Size,
|
|
advanced::{
|
|
layout,
|
|
widget::{Tree, operation::Focusable},
|
|
},
|
|
};
|
|
|
|
#[test]
|
|
fn five_slots_keep_editor_state_and_selection_through_explicit_and_passive_changes() {
|
|
let mut content =
|
|
widget::text_editor::Content::<FontLayout>::with_text("// 雨\r\n@assess\r\n");
|
|
content.move_to(widget::text_editor::Cursor {
|
|
position: widget::text_editor::Position { line: 1, column: 7 },
|
|
selection: Some(widget::text_editor::Position { line: 1, column: 1 }),
|
|
});
|
|
let expected_cursor = content.cursor();
|
|
let mut tree = Tree::empty();
|
|
let mut state_address = None;
|
|
for (find, drawer, issues) in [
|
|
(false, false, 0),
|
|
(false, false, 100),
|
|
(true, false, 1),
|
|
(true, true, 100),
|
|
(true, true, 0),
|
|
(false, true, 1),
|
|
(false, false, 0),
|
|
] {
|
|
let mut notices = column![];
|
|
for index in 0..issues {
|
|
notices = notices.push(text(format!("Diagnostic {index}")));
|
|
}
|
|
let editor = widget::text_editor(&content)
|
|
.on_action(Message::SourceEdit)
|
|
.height(Fill)
|
|
.font(Font::MONOSPACE)
|
|
.size(14)
|
|
.padding(16)
|
|
.highlight_with::<crate::highlight::ArcadiaHighlighter>(
|
|
content.text().into(),
|
|
crate::highlight::format,
|
|
);
|
|
let editor = widget::sensor(editor)
|
|
.key(("救援/assess.arcadia".to_owned(), None::<oa_core::Id>))
|
|
.on_show(|_| Message::SourceViewport(None))
|
|
.on_resize(|_| Message::SourceViewport(None));
|
|
let mut view = layout(
|
|
text("救援/assess.arcadia").into(),
|
|
text("Find").into(),
|
|
editor.into(),
|
|
scrollable(notices).height(Fill).into(),
|
|
text("Status").into(),
|
|
LayoutSettings {
|
|
find_open: find,
|
|
drawer_height: drawer.then_some(DRAWER_HEIGHT),
|
|
measured_drawer: Rc::new(Cell::new(0.)),
|
|
},
|
|
);
|
|
tree.diff(view.as_widget());
|
|
let node = view.as_widget_mut().layout(
|
|
&mut tree,
|
|
&FontLayout,
|
|
&layout::Limits::new(Size::ZERO, Size::new(720., 540.)),
|
|
);
|
|
assert_eq!(node.children().len(), 5);
|
|
let editor_bounds = node.children()[2].bounds();
|
|
assert_eq!(
|
|
editor_bounds.y,
|
|
STRIP_HEIGHT + if find { FIND_HEIGHT } else { 0. }
|
|
);
|
|
assert_eq!(
|
|
editor_bounds.height,
|
|
540. - STRIP_HEIGHT
|
|
- STATUS_HEIGHT
|
|
- if find { FIND_HEIGHT } else { 0. }
|
|
- if drawer { DRAWER_HEIGHT } else { 0. }
|
|
);
|
|
assert!(editor_bounds.height >= 240.);
|
|
// Container delegates to the permanent sensor, whose child is the
|
|
// actual highlighted editor, matching the production wrapper path.
|
|
let state = tree.children[2].children[0]
|
|
.state
|
|
.downcast_mut::<widget::text_editor::State<crate::highlight::ArcadiaHighlighter>>();
|
|
let address = state as *mut _ as usize;
|
|
if let Some(expected) = state_address {
|
|
assert_eq!(
|
|
address, expected,
|
|
"the actual editor state must be retained"
|
|
);
|
|
assert!(state.is_focused());
|
|
} else {
|
|
state.focus();
|
|
state_address = Some(address);
|
|
}
|
|
assert_eq!(content.cursor(), expected_cursor);
|
|
assert_eq!(content.selection().as_deref(), Some("assess"));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn drawer_clamps_to_actual_center_allocation_including_oversize_requests() {
|
|
for (host, find, request, expected_drawer, expected_editor) in [
|
|
(Size::new(720., 480.), true, 1200., 180., 200.),
|
|
(Size::new(720., 540.), true, 1200., 200., 240.),
|
|
(Size::new(1180., 890.), false, 1200., 586., 240.),
|
|
(Size::new(1180., 890.), true, 280., 280., 510.),
|
|
(Size::new(720., 540.), false, 180., 180., 296.),
|
|
] {
|
|
let measured = Rc::new(Cell::new(0.));
|
|
let mut view: Element<'_, Message, iced::Theme, FontLayout> = layout(
|
|
text("Strip").into(),
|
|
text("Find").into(),
|
|
text("Editor").into(),
|
|
text("Drawer").into(),
|
|
text("Status").into(),
|
|
LayoutSettings {
|
|
find_open: find,
|
|
drawer_height: Some(request),
|
|
measured_drawer: measured.clone(),
|
|
},
|
|
);
|
|
let mut tree = Tree::new(view.as_widget());
|
|
let node = view.as_widget_mut().layout(
|
|
&mut tree,
|
|
&FontLayout,
|
|
&layout::Limits::new(Size::ZERO, host),
|
|
);
|
|
assert_eq!(node.size(), host);
|
|
assert_eq!(node.children().len(), 5);
|
|
assert_eq!(node.children()[3].bounds().height, expected_drawer);
|
|
assert_eq!(node.children()[2].bounds().height, expected_editor);
|
|
assert_eq!(measured.get(), expected_drawer);
|
|
assert_eq!(node.children()[4].bounds().y + STATUS_HEIGHT, host.height);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn keyboard_resize_uses_effective_height_and_only_exact_open_drawer_shortcut() {
|
|
use iced::keyboard::{
|
|
self, Key, Modifiers,
|
|
key::{Code, Named, Physical},
|
|
};
|
|
for (open, modifiers, named, expected) in [
|
|
(true, Modifiers::ALT, Named::PageUp, Some(140.)),
|
|
(true, Modifiers::ALT, Named::PageDown, Some(260.)),
|
|
(false, Modifiers::ALT, Named::PageDown, None),
|
|
(
|
|
true,
|
|
Modifiers::CTRL | Modifiers::ALT,
|
|
Named::PageDown,
|
|
None,
|
|
),
|
|
(true, Modifiers::ALT | Modifiers::SHIFT, Named::PageUp, None),
|
|
] {
|
|
let mut view: Element<'_, Message, iced::Theme, FontLayout> = layout(
|
|
text("Strip").into(),
|
|
text("Find").into(),
|
|
text("Editor").into(),
|
|
text("Drawer").into(),
|
|
text("Status").into(),
|
|
LayoutSettings {
|
|
find_open: true,
|
|
drawer_height: open.then_some(1200.),
|
|
measured_drawer: Rc::new(Cell::new(0.)),
|
|
},
|
|
);
|
|
let mut tree = Tree::new(view.as_widget());
|
|
let host = Size::new(720., 540.);
|
|
let node = view.as_widget_mut().layout(
|
|
&mut tree,
|
|
&FontLayout,
|
|
&layout::Limits::new(Size::ZERO, host),
|
|
);
|
|
let event = iced::Event::Keyboard(keyboard::Event::KeyPressed {
|
|
key: Key::Named(named),
|
|
modified_key: Key::Named(named),
|
|
physical_key: Physical::Code(Code::PageDown),
|
|
location: keyboard::Location::Standard,
|
|
modifiers,
|
|
text: None,
|
|
repeat: false,
|
|
});
|
|
let mut messages = Vec::new();
|
|
let mut shell = iced::advanced::Shell::new(&mut messages);
|
|
view.as_widget_mut().update(
|
|
&mut tree,
|
|
&event,
|
|
iced::advanced::Layout::new(&node),
|
|
iced::mouse::Cursor::Unavailable,
|
|
&FontLayout,
|
|
&mut iced::advanced::clipboard::Null,
|
|
&mut shell,
|
|
&iced::Rectangle::with_size(host),
|
|
);
|
|
assert_eq!(shell.is_event_captured(), expected.is_some());
|
|
assert_eq!(messages.len(), usize::from(expected.is_some()));
|
|
if let Some(expected) = expected {
|
|
assert!(
|
|
matches!(messages[0], Message::TextWorkspace(Event::ResizeDrawer(actual)) if actual == expected)
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct ScrollProbe {
|
|
scroll: Option<(usize, f32)>,
|
|
}
|
|
impl iced::advanced::renderer::Renderer for ScrollProbe {
|
|
fn start_layer(&mut self, _: iced::Rectangle) {}
|
|
fn end_layer(&mut self) {}
|
|
fn start_transformation(&mut self, _: iced::Transformation) {}
|
|
fn end_transformation(&mut self) {}
|
|
fn reset(&mut self, _: iced::Rectangle) {}
|
|
fn fill_quad(&mut self, _: iced::advanced::renderer::Quad, _: impl Into<iced::Background>) {
|
|
}
|
|
fn allocate_image(
|
|
&mut self,
|
|
handle: &iced::advanced::image::Handle,
|
|
callback: impl FnOnce(
|
|
Result<iced::advanced::image::Allocation, iced::advanced::image::Error>,
|
|
) + Send
|
|
+ 'static,
|
|
) {
|
|
iced::advanced::renderer::Renderer::allocate_image(&mut (), handle, callback);
|
|
}
|
|
}
|
|
impl iced::advanced::text::Renderer for ScrollProbe {
|
|
type Font = Font;
|
|
type Paragraph = iced::advanced::graphics::text::Paragraph;
|
|
type Editor = iced::advanced::graphics::text::Editor;
|
|
const ICON_FONT: Font = Font::DEFAULT;
|
|
const CHECKMARK_ICON: char = '0';
|
|
const ARROW_DOWN_ICON: char = '0';
|
|
const SCROLL_UP_ICON: char = '0';
|
|
const SCROLL_DOWN_ICON: char = '0';
|
|
const SCROLL_LEFT_ICON: char = '0';
|
|
const SCROLL_RIGHT_ICON: char = '0';
|
|
const ICED_LOGO: char = '0';
|
|
fn default_font(&self) -> Font {
|
|
Font::DEFAULT
|
|
}
|
|
fn default_size(&self) -> iced::Pixels {
|
|
iced::Pixels(16.)
|
|
}
|
|
fn fill_paragraph(
|
|
&mut self,
|
|
_: &Self::Paragraph,
|
|
_: iced::Point,
|
|
_: iced::Color,
|
|
_: iced::Rectangle,
|
|
) {
|
|
}
|
|
fn fill_editor(
|
|
&mut self,
|
|
editor: &Self::Editor,
|
|
_: iced::Point,
|
|
_: iced::Color,
|
|
_: iced::Rectangle,
|
|
) {
|
|
let scroll = editor.buffer().scroll();
|
|
self.scroll = Some((scroll.line, scroll.vertical));
|
|
}
|
|
fn fill_text(
|
|
&mut self,
|
|
_: iced::advanced::text::Text,
|
|
_: iced::Point,
|
|
_: iced::Color,
|
|
_: iced::Rectangle,
|
|
) {
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn highlighted_sensor_editor_retains_real_scrolled_buffer_across_panel_rebuilds() {
|
|
let source = (0..100)
|
|
.map(|index| format!("// 雨 {index} @assess\r\n"))
|
|
.collect::<String>();
|
|
let mut content = widget::text_editor::Content::<ScrollProbe>::with_text(&source);
|
|
content.move_to(widget::text_editor::Cursor {
|
|
position: widget::text_editor::Position { line: 2, column: 6 },
|
|
selection: Some(widget::text_editor::Position { line: 2, column: 3 }),
|
|
});
|
|
let cursor = content.cursor();
|
|
let mut tree = Tree::empty();
|
|
let mut probe = ScrollProbe::default();
|
|
let mut baseline_scroll = None;
|
|
let host = Size::new(720., 540.);
|
|
// First iteration sends a real wheel event and applies its editor action.
|
|
for (iteration, (find, drawer)) in [
|
|
(false, false),
|
|
(false, false),
|
|
(true, false),
|
|
(true, true),
|
|
(true, true),
|
|
(false, true),
|
|
(false, false),
|
|
]
|
|
.into_iter()
|
|
.enumerate()
|
|
{
|
|
let mut messages = Vec::new();
|
|
{
|
|
let editor = widget::text_editor(&content)
|
|
.on_action(Message::SourceEdit)
|
|
.height(Fill)
|
|
.font(Font::MONOSPACE)
|
|
.size(14)
|
|
.padding(16)
|
|
.highlight_with::<crate::highlight::ArcadiaHighlighter>(
|
|
source.clone().into(),
|
|
crate::highlight::format,
|
|
);
|
|
let editor = widget::sensor(editor)
|
|
.key(("救援/assess.arcadia".to_owned(), None::<oa_core::Id>))
|
|
.on_show(|_| Message::SourceViewport(None))
|
|
.on_resize(|_| Message::SourceViewport(None));
|
|
let mut view = layout(
|
|
text("Strip").into(),
|
|
text("Find").into(),
|
|
editor.into(),
|
|
text("Details").into(),
|
|
text("Status").into(),
|
|
LayoutSettings {
|
|
find_open: find,
|
|
drawer_height: drawer.then_some(180.),
|
|
measured_drawer: Rc::new(Cell::new(0.)),
|
|
},
|
|
);
|
|
tree.diff(view.as_widget());
|
|
let node = view.as_widget_mut().layout(
|
|
&mut tree,
|
|
&probe,
|
|
&layout::Limits::new(Size::ZERO, host),
|
|
);
|
|
let state = tree.children[2].children[0].state.downcast_mut::<widget::text_editor::State<crate::highlight::ArcadiaHighlighter>>();
|
|
if iteration == 0 {
|
|
state.focus();
|
|
} else {
|
|
assert!(state.is_focused());
|
|
}
|
|
if iteration == 0 {
|
|
let mut shell = iced::advanced::Shell::new(&mut messages);
|
|
view.as_widget_mut().update(
|
|
&mut tree,
|
|
&iced::Event::Mouse(iced::mouse::Event::WheelScrolled {
|
|
delta: iced::mouse::ScrollDelta::Pixels { x: 0., y: -120. },
|
|
}),
|
|
iced::advanced::Layout::new(&node),
|
|
iced::mouse::Cursor::Available(iced::Point::new(100., 120.)),
|
|
&probe,
|
|
&mut iced::advanced::clipboard::Null,
|
|
&mut shell,
|
|
&iced::Rectangle::with_size(host),
|
|
);
|
|
assert!(shell.is_event_captured());
|
|
} else {
|
|
view.as_widget().draw(
|
|
&tree,
|
|
&mut probe,
|
|
&style::theme(),
|
|
&iced::advanced::renderer::Style {
|
|
text_color: style::INK,
|
|
},
|
|
iced::advanced::Layout::new(&node),
|
|
iced::mouse::Cursor::Unavailable,
|
|
&iced::Rectangle::with_size(host),
|
|
);
|
|
let scroll = probe.scroll.expect("actual highlighted editor was drawn");
|
|
assert!(
|
|
scroll.0 > 0 || scroll.1 > 0.,
|
|
"the fixture must actually scroll"
|
|
);
|
|
if let Some(expected) = baseline_scroll {
|
|
assert_eq!(scroll, expected);
|
|
} else {
|
|
baseline_scroll = Some(scroll);
|
|
}
|
|
}
|
|
}
|
|
if iteration == 0 {
|
|
assert!(messages.iter().any(|message| matches!(
|
|
message,
|
|
Message::SourceEdit(widget::text_editor::Action::Scroll { .. })
|
|
)));
|
|
}
|
|
for message in messages {
|
|
if let Message::SourceEdit(action) = message {
|
|
content.perform(action);
|
|
}
|
|
}
|
|
assert_eq!(content.cursor(), cursor);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(all(test, debug_assertions))]
|
|
mod summary_visibility_tests;
|