Files
ARCADIA-Studio/crates/oa-desktop/src/views/text_workspace.rs
T

1176 lines
43 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, DrawerContext, 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>,
references_current: bool,
) -> 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())
.style(style::source_editor)
.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, references_current),
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(
app.text_drawer_context(),
destination,
))),
);
}
tabs.push(widget::Space::new().width(Fill))
.push(resize_button(
"Smaller",
measured_drawer.clone(),
-60.,
app.text_drawer_context(),
))
.push(resize_button(
"Larger",
measured_drawer.clone(),
60.,
app.text_drawer_context(),
))
.push(compact_action(
"Hide",
Message::TextWorkspace(Event::HideDrawer(app.text_drawer_context())),
))
.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(
app.text_drawer_context(),
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 {
drawer_context: app.text_drawer_context(),
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,
context: DrawerContext,
) -> Element<'static, Message> {
widget::tooltip(
button(text(label).size(12))
.padding([4, 8])
.style(style::nav)
.on_press_with(move || {
Message::TextWorkspace(Event::ResizeDrawer(context, 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,
current: bool,
) -> 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 !current {
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 drawer_context: DrawerContext,
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.drawer_context,
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 {
drawer_context: DrawerContext {
document: oa_core::Id::nil(),
session: oa_core::Id::nil(),
visit: oa_core::Id::nil(),
},
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 {
drawer_context: DrawerContext {
document: oa_core::Id::nil(),
session: oa_core::Id::nil(),
visit: oa_core::Id::nil(),
},
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 {
drawer_context: DrawerContext {
document: oa_core::Id::nil(),
session: oa_core::Id::nil(),
visit: oa_core::Id::nil(),
},
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(context, actual)) if actual == expected && context.document == oa_core::Id::nil() && context.session == oa_core::Id::nil() && context.visit == oa_core::Id::nil())
);
}
}
}
#[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 {
drawer_context: DrawerContext {
document: oa_core::Id::nil(),
session: oa_core::Id::nil(),
visit: oa_core::Id::nil(),
},
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))]
pub(crate) mod summary_visibility_tests;
/// The same retained layout and Alt+PageDown publisher as the production view;
/// only its five content slots are inert for this CPU integration witness.
#[cfg(all(test, debug_assertions))]
pub(super) fn capture_drawer_resize(app: &App) -> Message {
use crate::views::source_tests::FontLayout;
use iced::advanced::{Layout, Shell, layout as core_layout, widget::Tree};
use iced::keyboard::{self, Key, Modifiers};
let mut view: Element<'_, Message, iced::Theme, FontLayout> = layout(
widget::Space::new().into(),
widget::Space::new().into(),
widget::Space::new().into(),
widget::Space::new().into(),
widget::Space::new().into(),
LayoutSettings {
drawer_context: app.text_drawer_context(),
find_open: false,
drawer_height: Some(app.text_workspace.drawer_height()),
measured_drawer: Rc::new(Cell::new(0.)),
},
);
let mut tree = Tree::new(view.as_widget());
let host = iced::Size::new(720., 700.);
let node = view.as_widget_mut().layout(
&mut tree,
&FontLayout,
&core_layout::Limits::new(iced::Size::ZERO, host),
);
let event = iced::Event::Keyboard(keyboard::Event::KeyPressed {
key: Key::Named(keyboard::key::Named::PageDown),
modified_key: Key::Named(keyboard::key::Named::PageDown),
physical_key: keyboard::key::Physical::Code(keyboard::key::Code::PageDown),
location: keyboard::Location::Standard,
modifiers: Modifiers::ALT,
text: None,
repeat: false,
});
let mut messages = Vec::new();
let mut shell = Shell::new(&mut messages);
view.as_widget_mut().update(
&mut tree,
&event,
Layout::new(&node),
iced::mouse::Cursor::Unavailable,
&FontLayout,
&mut iced::advanced::clipboard::Null,
&mut shell,
&iced::Rectangle::with_size(host),
);
assert!(shell.is_event_captured());
assert_eq!(messages.len(), 1);
let message = messages.remove(0);
assert!(
matches!(&message, Message::TextWorkspace(Event::ResizeDrawer(context, height))
if *context == app.text_drawer_context() && *height > app.text_workspace.drawer_height())
);
message
}