live preview: Implement resizing and moving of selected eleement

Much polish needed, but it is a basis to build upon.
This commit is contained in:
Tobias Hunger 2024-02-16 12:28:22 +01:00 committed by Tobias Hunger
parent e1aefc6f16
commit 08372e5a07
10 changed files with 427 additions and 44 deletions

View file

@ -195,6 +195,19 @@ pub struct ComponentAddition {
pub component_text: String,
}
#[allow(unused)]
#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)]
pub struct PropertyChange {
pub name: String,
pub value: String,
}
impl PropertyChange {
pub fn new(name: &str, value: String) -> Self {
PropertyChange { name: name.to_string(), value }
}
}
#[allow(unused)]
#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)]
pub enum PreviewToLspMessage {
@ -204,6 +217,7 @@ pub enum PreviewToLspMessage {
PreviewTypeChanged { is_external: bool },
RequestState { unused: bool }, // send all documents!
AddComponent { label: Option<String>, component: ComponentAddition },
UpdateElement { position: VersionedPosition, properties: Vec<PropertyChange> },
}
/// Information on the Element types available

View file

@ -1419,6 +1419,45 @@ pub fn add_component(
.ok_or("Could not create workspace edit".into())
}
pub fn update_element(
ctx: &Context,
position: crate::common::VersionedPosition,
properties: Vec<crate::common::PropertyChange>,
) -> Result<lsp_types::WorkspaceEdit> {
let mut document_cache = ctx.document_cache.borrow_mut();
let file = lsp_types::Url::to_file_path(position.url())
.map_err(|_| "Failed to convert URL to file path".to_string())?;
if &document_cache.document_version(position.url()) != position.version() {
return Err("Document version mismatch.".into());
}
let doc = document_cache
.documents
.get_document(&file)
.ok_or_else(|| "Document not found".to_string())?;
let source_file = doc
.node
.as_ref()
.map(|n| n.source_file.clone())
.ok_or_else(|| "Document had no node".to_string())?;
let element_position = map_position(&source_file, position.offset().into());
let element = element_at_position(&mut document_cache, &position.url(), &element_position)
.ok_or_else(|| {
format!("No element found at the given start position {:?}", &element_position)
})?;
let (_, e) = crate::language::properties::set_bindings(
&mut document_cache,
position.url(),
&element,
&properties,
)?;
Ok(e.ok_or_else(|| "Failed to create workspace edit".to_string())?)
}
#[cfg(test)]
pub mod tests {
use super::*;

View file

@ -677,6 +677,47 @@ pub(crate) fn set_binding(
}
}
pub(crate) fn set_bindings(
document_cache: &mut DocumentCache,
uri: &lsp_types::Url,
element: &ElementRc,
properties: &[crate::common::PropertyChange],
) -> Result<(SetBindingResponse, Option<lsp_types::WorkspaceEdit>)> {
let version = document_cache.document_version(uri);
let (responses, edits) = properties
.iter()
.map(|p| set_binding(document_cache, uri, element, &p.name, p.value.clone()))
.fold(
Ok((SetBindingResponse { diagnostics: Default::default() }, Vec::new())),
|prev_result: Result<(SetBindingResponse, Vec<lsp_types::TextEdit>)>, next_result| {
let (mut responses, mut edits) = prev_result?;
let (nr, ne) = next_result?;
responses.diagnostics.extend_from_slice(&nr.diagnostics);
match ne {
Some(lsp_types::WorkspaceEdit {
document_changes: Some(lsp_types::DocumentChanges::Edits(e)),
..
}) => {
edits.extend(e.get(0).unwrap().edits.iter().filter_map(|e| match e {
lsp_types::OneOf::Left(edit) => Some(edit.clone()),
_ => None,
}));
}
_ => { /* do nothing */ }
};
Ok((responses, edits))
},
)?;
if edits.is_empty() {
Ok((responses, None))
} else {
Ok((responses, Some(crate::common::create_workspace_edit(uri.clone(), version, edits))))
}
}
fn create_workspace_edit_for_remove_binding(
uri: &lsp_types::Url,
version: SourceFileVersion,

View file

@ -440,7 +440,13 @@ async fn handle_preview_to_lsp_message(
crate::language::request_state(ctx);
}
M::AddComponent { label, component } => {
let edit = crate::language::add_component(ctx, component)?;
let edit = match crate::language::add_component(ctx, component) {
Ok(edit) => edit,
Err(e) => {
eprintln!("Error: {}", e);
return Ok(());
}
};
let response = ctx
.server_notifier
.send_request::<lsp_types::request::ApplyWorkspaceEdit>(
@ -454,6 +460,30 @@ async fn handle_preview_to_lsp_message(
.into());
}
}
M::UpdateElement { position, properties } => {
let edit = match crate::language::update_element(ctx, position, properties) {
Ok(e) => e,
Err(e) => {
eprintln!("Error: {e}");
return Ok(());
}
};
let response = ctx
.server_notifier
.send_request::<lsp_types::request::ApplyWorkspaceEdit>(
lsp_types::ApplyWorkspaceEditParams {
label: Some("Element update".to_string()),
edit,
},
)?
.await?;
if !response.applied {
return Err(response
.failure_reason
.unwrap_or("Operation failed, no specific reason given".into())
.into());
}
}
}
Ok(())
}

View file

@ -2,7 +2,8 @@
// SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-1.1 OR LicenseRef-Slint-commercial
use crate::common::{
ComponentInformation, PreviewComponent, PreviewConfig, UrlVersion, VersionedUrl,
ComponentInformation, PreviewComponent, PreviewConfig, UrlVersion, VersionedPosition,
VersionedUrl,
};
use crate::lsp_ext::Health;
use crate::preview::element_selection::ElementSelection;
@ -124,6 +125,64 @@ fn drop_component(
};
}
// triggered from the UI, running in UI thread
fn change_geometry_of_selected_element(x: f32, y: f32, width: f32, height: f32) {
let Some(selected) = PREVIEW_STATE.with(move |preview_state| {
let preview_state = preview_state.borrow();
preview_state.selected.clone()
}) else {
return;
};
let Some(selected_element) = selected_element() else {
return;
};
let Some(component_instance) = component_instance() else {
return;
};
let Some(geometry) = component_instance
.element_position(&selected_element)
.get(selected.instance_index)
.cloned()
else {
return;
};
let properties = {
let mut p = Vec::with_capacity(4);
if geometry.origin.x != x {
p.push(crate::common::PropertyChange::new("x", format!("{}px", x.round())));
}
if geometry.origin.y != y {
p.push(crate::common::PropertyChange::new("y", format!("{}px", y.round())));
}
if geometry.size.width != width {
p.push(crate::common::PropertyChange::new("width", format!("{}px", width.round())));
}
if geometry.size.height != height {
p.push(crate::common::PropertyChange::new("height", format!("{}px", height.round())));
}
p
};
if !properties.is_empty() {
let Ok(url) = Url::from_file_path(&selected.path) else {
return;
};
let cache = CONTENT_CACHE.get_or_init(Default::default).lock().unwrap();
let Some((version, _)) = cache.source_code.get(&url).cloned() else {
return;
};
send_message_to_lsp(crate::common::PreviewToLspMessage::UpdateElement {
position: VersionedPosition::new(VersionedUrl::new(url, version), selected.offset),
properties,
});
}
}
fn change_style() {
let cache = CONTENT_CACHE.get_or_init(Default::default).lock().unwrap();
let ui_is_visible = cache.ui_is_visible;
@ -515,6 +574,7 @@ fn set_selections(
x: g.origin.x,
y: g.origin.y,
border_color: if i == main_index { border_color } else { secondary_border_color },
is_primary: i == main_index,
})
.collect::<Vec<_>>();
let model = Rc::new(slint::VecModel::from(values));

View file

@ -45,6 +45,7 @@ pub fn create_ui(style: String, experimental: bool) -> Result<PreviewUi, Platfor
ui.on_select_behind(super::element_selection::select_element_behind);
ui.on_can_drop(super::can_drop_component);
ui.on_drop(super::drop_component);
ui.on_selected_element_update_geometry(super::change_geometry_of_selected_element);
Ok(ui)
}

View file

@ -19,6 +19,56 @@ export struct Selection {
width: length,
height: length,
border-color: color,
is-primary: bool,
}
component SelectionFrame {
in property <Selection> selection;
x: root.selection.x;
y: root.selection.y;
width: root.selection.width;
height: root.selection.height;
callback update-geometry(/* x */ length, /* y */ length, /* width */ length, /* height */ length);
if !selection.is-primary: Rectangle {
x: 0;
y: 0;
border-color: root.selection.border-color;
border-width: 1px;
}
if selection.is-primary: Resizer {
is-moveable: true;
is-resizable: true;
x-position: root.x;
y-position: root.y;
color: root.selection.border-color;
x: 0;
y: 0;
width: root.width;
height: root.height;
update-geometry(x, y, w, h, done) => {
root.x = x;
root.y = y;
root.width = w;
root.height = h;
if done {
root.update-geometry(x, y, w, h);
}
}
inner := Rectangle {
border-color: root.selection.border-color;
border-width: 1px;
background: parent.resizing || parent.moving ? root.selection.border-color.with-alpha(0.5) : root.selection.border-color.with-alpha(0.0);
}
}
}
export component DrawArea {
@ -38,6 +88,7 @@ export component DrawArea {
callback select-behind(/* x */ length, /* y */ length, /* enter_component? */ bool, /* reverse */ bool);
callback show-document(/* url */ string, /* line */ int, /* column */ int);
callback unselect();
callback selected-element-update-geometry(/* x */ length, /* y */ length, /* width */ length, /* height */ length);
preferred-height: max(i-preview-area-container.preferred-height, i-preview-area-container.min-height) + 2 * i-scroll-view.border;
preferred-width: max(i-preview-area-container.preferred-width, i-preview-area-container.min-width) + 2 * i-scroll-view.border;
@ -51,8 +102,8 @@ export component DrawArea {
i-drawing-rect := Rectangle {
background: Palette.alternate-background;
width: max(i-scroll-view.visible-width, i-resizer.width + i-scroll-view.border);
height: max(i-scroll-view.visible-height, i-resizer.height + i-scroll-view.border);
width: max(i-scroll-view.visible-width, main-resizer.width + i-scroll-view.border);
height: max(i-scroll-view.visible-height, main-resizer.height + i-scroll-view.border);
unselect-area := TouchArea {
clicked => { root.unselect(); }
@ -61,18 +112,22 @@ export component DrawArea {
}
i-content-border := Rectangle {
x: i-resizer.x + (i-resizer.width - self.width) / 2;
y: i-resizer.y + (i-resizer.height - self.height) / 2;
width: i-resizer.width + 2 * self.border-width;
height: i-resizer.height + 2 * self.border-width;
x: main-resizer.x + (main-resizer.width - self.width) / 2;
y: main-resizer.y + (main-resizer.height - self.height) / 2;
width: main-resizer.width + 2 * self.border-width;
height: main-resizer.height + 2 * self.border-width;
border-width: 1px;
border-color: Palette.border;
}
i-resizer := Resizer {
main-resizer := Resizer {
is-moveable: false;
is-resizable <=> i-preview-area-container.is-resizable;
resize(w, h) => {
x-position: parent.x;
y-position: parent.y;
update-geometry(_, _, w, h) => {
i-preview-area-container.width = clamp(w, i-preview-area-container.min-width, i-preview-area-container.max-width);
i-preview-area-container.height = clamp(h, i-preview-area-container.min-height, i-preview-area-container.max-height);
}
@ -93,7 +148,6 @@ export component DrawArea {
}
i-preview-area-container := ComponentContainer {
property <bool> is-resizable: (self.min-width != self.max-width && self.min-height != self.max-height) && self.has-component;
component-factory <=> root.preview-area;
@ -103,6 +157,7 @@ export component DrawArea {
// Instead, we use a init function to initialize
width: 0px;
height: 0px;
init => {
self.width = max(self.preferred-width, self.min-width);
self.height = max(self.preferred-height, self.min-height);
@ -141,13 +196,11 @@ export component DrawArea {
}
i-selection-display-area := Rectangle {
for s in root.selections: Rectangle {
x: s.x;
y: s.y;
width: s.width;
height: s.height;
border-color: s.border-color;
border-width: 1px;
for s in root.selections: SelectionFrame {
selection: s;
update-geometry(x, y, w, h) => {
root.selected-element-update-geometry(x, y, w, h);
}
}
}
}

View file

@ -25,6 +25,7 @@ export component PreviewUi inherits Window {
pure callback can-drop(/* component_type */ string, /* x */ length, /* y */ length) -> bool;
callback drop(/* component_type */ string, /* import_path */ string, /* is_layout */ bool, /* x */ length, /* y */ length);
callback selected-element-update-geometry(/* x */ length, /* y */ length, /* width */ length, /* height */ length);
callback select-at(/* x */ length, /* y */ length, /* enter_component? */ bool);
callback select-behind(/* x */ length, /* y */ length, /* enter_component* */ bool, /* reverse */ bool);
callback show-document(/* url */ string, /* line */ int, /* column */ int);
@ -115,6 +116,7 @@ export component PreviewUi inherits Window {
selections <=> root.selections;
select-at(x, y, enter_component) => { root.select-at(x, y, enter_component); }
selected-element-update-geometry(x, y, w, h) => { root.selected-element-update-geometry(x, y, w, h); }
select-behind(x, y, stay_in_file, reverse) => { root.select-behind(x, y, stay_in_file, reverse); }
show-document(url, line, column) => { root.show-document(url, line, column); }
unselect() => { root.unselect(); }

View file

@ -6,8 +6,17 @@ global ResizeState {
}
component ResizeHandle inherits Rectangle {
callback resize(/* width */ length, /* height */ length);
in property <MouseCursor> mouse-cursor;
out property <bool> resizing <=> ta.pressed;
callback resize(/* width */ length, /* height */ length, /* done? */ bool);
callback resize-done(/* width */ length, /* height */ length);
// Internal!
in-out property <length> new-width;
in-out property <length> new-height;
in-out property <length> new-x;
in-out property <length> new-y;
width: ResizeState.handle-size;
height: ResizeState.handle-size;
@ -16,74 +25,185 @@ component ResizeHandle inherits Rectangle {
border-color: Colors.white;
border-width: 1px;
TouchArea {
moved() => {
root.resize(self.mouse-x - self.pressed-x, self.mouse-y - self.pressed-y);
}
ta := TouchArea {
private property <length> x-offset: self.mouse-x - self.pressed-x;
private property <length> y-offset: self.mouse-y - self.pressed-y;
mouse-cursor <=> root.mouse-cursor;
moved() => {
root.resize(x-offset, y-offset, false);
}
pointer-event(event) => {
if event.button == PointerEventButton.left && event.kind == PointerEventKind.up {
root.resize(x-offset, y-offset, true);
}
}
}
}
export component Resizer {
in property <bool> is-resizable: true;
callback resize(/* width */ length, /* height */ length);
in property <length> x-position;
in property <length> y-position;
property <length> handle-size: ResizeState.handle-size;
in property <bool> is-resizable: true;
in property <bool> is-moveable: false;
in property <color> color: Colors.black;
out property <bool> resizing: n.resizing || ne.resizing || e.resizing || se.resizing || s.resizing || sw.resizing || w.resizing || nw.resizing;
out property <bool> moving: resize-area.moving;
out property <length> handle-size: ResizeState.handle-size;
callback update-geometry(/* x */ length, /* y */ length, /* width */ length, /* height */ length, /* done */ bool);
// Private!
in-out property <length> top: self.y-position;
in-out property <length> bottom: self.y-position + self.height;
in-out property <length> left: self.x-position;
in-out property <length> right: self.x-position + self.width;
resize-area := Rectangle {
width <=> parent.width;
height <=> parent.height;
in-out property <bool> moving: false;
width <=> root.width;
height <=> root.height;
@children
if root.is-moveable: TouchArea {
private property <length> x-offset: self.mouse-x - self.pressed-x;
private property <length> y-offset: self.mouse-y - self.pressed-y;
private property <bool> has-moved;
mouse-cursor: MouseCursor.move;
moved() => {
root.update-geometry(root.x-position + x-offset, root.y-position + y-offset, root.width, root.height, false);
self.has-moved = true;
}
pointer-event(event) => {
resize-area.moving = self.pressed;
if self.has-moved && event.button == PointerEventButton.left && event.kind == PointerEventKind.up {
root.update-geometry(root.x-position + x-offset, root.y-position + y-offset, root.width, root.height, true);
self.has-moved = false;
}
}
}
}
Rectangle {
visible: is-resizable;
ResizeHandle { // N
resize(x-offset, y-offset) => { root.resize(root.width, y-offset * -1.0 + root.height); }
n := ResizeHandle {
background: root.color;
resize(x-offset, y-offset, done) => {
self.new-width = root.width;
self.new-height = Math.max(0px, y-offset * -1.0 + root.height);
self.new-x = root.left;
self.new-y = root.bottom - self.new-height;
root.update-geometry(self.new-x, self.new-y, self.new-width, self.new-height, done);
}
mouse-cursor: MouseCursor.n-resize;
x: (root.width - root.handle-size) / 2.0;
y: -root.handle-size;
}
ResizeHandle { // NE
resize(x-offset, y-offset) => { root.resize(x-offset + root.width, y-offset * -1.0 + root.height); }
ne := ResizeHandle {
background: root.color;
resize(x-offset, y-offset, done) => {
self.new-width = Math.max(0px, x-offset + root.width);
self.new-height = Math.max(0px, y-offset * -1.0 + root.height);
self.new-x = root.left;
self.new-y = root.bottom - self.new-height;
root.update-geometry(self.new-x, self.new-y, self.new-width, self.new-height, done);
}
mouse-cursor: MouseCursor.ne-resize;
x: root.width;
y: -root.handle-size;
}
ResizeHandle { // E
resize(x-offset, y-offset) => { root.resize(x-offset + root.width, root.height); }
e := ResizeHandle {
background: root.color;
resize(x-offset, y-offset, done) => {
self.new-width = Math.max(0px, x-offset + root.width);
self.new-height = root.height;
self.new-x = root.left;
self.new-y = root.top;
root.update-geometry(self.new-x, self.new-y, self.new-width, self.new-height, done);
}
mouse-cursor: MouseCursor.e-resize;
x: root.width;
y: (root.height - root.handle-size) / 2.0;
}
ResizeHandle { // SE
resize(x-offset, y-offset) => { root.resize(x-offset + root.width, y-offset + root.height); }
se := ResizeHandle {
background: root.color;
resize(x-offset, y-offset, done) => {
self.new-width = Math.max(0px, x-offset + root.width);
self.new-height = Math.max(0px, y-offset + root.height);
self.new-x = root.left;
self.new-y = root.top;
root.update-geometry(self.new-x, self.new-y, self.new-width, self.new-height, done);
}
mouse-cursor: MouseCursor.se-resize;
x: root.width;
y: root.height;
}
ResizeHandle { // S
resize(x-offset, y-offset) => { root.resize(root.width, y-offset + root.height); }
s := ResizeHandle {
background: root.color;
resize(x-offset, y-offset, done) => {
self.new-width = root.width;
self.new-height = Math.max(0px, y-offset + root.height);
self.new-x = root.left;
self.new-y = root.top;
root.update-geometry(self.new-x, self.new-y, self.new-width, self.new-height, done);
}
mouse-cursor: MouseCursor.s-resize;
x: (root.width - root.handle-size) / 2.0;
y: root.height;
}
ResizeHandle { // SW
resize(x-offset, y-offset) => { root.resize(x-offset * -1.0 + root.width, y-offset + root.height); }
sw := ResizeHandle {
background: root.color;
resize(x-offset, y-offset, done) => {
self.new-width = Math.max(0px, x-offset * -1.0 + root.width);
self.new-height = Math.max(0px, y-offset + root.height);
self.new-x = root.right - self.new-width;
self.new-y = root.top;
root.update-geometry(self.new-x, self.new-y, self.new-width, self.new-height, done);
}
mouse-cursor: MouseCursor.sw-resize;
x: -root.handle-size;
y: root.height;
}
ResizeHandle { // W
resize(x-offset, y-offset) => { root.resize(x-offset * -1.0 + root.width, root.height); }
w := ResizeHandle {
background: root.color;
resize(x-offset, y-offset, done) => {
self.new-width = Math.max(0px, x-offset * -1.0 + root.width);
self.new-height = root.height;
self.new-x = root.right - self.new-width;
self.new-y = root.top;
root.update-geometry(self.new-x, self.new-y, self.new-width, self.new-height, done);
}
mouse-cursor: MouseCursor.w-resize;
x: -root.handle-size;
y: (root.height - root.handle-size) / 2.0;
}
ResizeHandle { // NW
resize(x-offset, y-offset) => { root.resize(x-offset * -1.0 + root.width, y-offset * -1.0 + root.height); }
nw := ResizeHandle {
background: root.color;
resize(x-offset, y-offset, done) => {
self.new-width = Math.max(0px, x-offset * -1.0 + root.width);
self.new-height = Math.max(0px, y-offset * -1.0 + root.height);
self.new-x = root.right - self.new-width;
self.new-y = root.bottom - self.new-height;
root.update-geometry(self.new-x, self.new-y, self.new-width, self.new-height, done);
}
mouse-cursor: MouseCursor.nw-resize;
x: -root.handle-size;
y: -root.handle-size;

View file

@ -291,6 +291,29 @@ impl SlintServer {
});
}
}
M::UpdateElement { position, properties } => {
let edit = crate::language::update_element(&self.ctx, position, properties);
let sn = self.ctx.server_notifier.clone();
if let Ok(edit) = edit {
wasm_bindgen_futures::spawn_local(async move {
let fut = sn.send_request::<lsp_types::request::ApplyWorkspaceEdit>(
lsp_types::ApplyWorkspaceEditParams {
label: Some("Element update".to_string()),
edit,
},
);
if let Ok(fut) = fut {
// We ignore errors: If the LSP can not be reached, then all is lost
// anyway. The other thing that might go wrong is that our Workspace Edit
// refers to some outdated text. In that case the update is most likely
// in flight already and will cause the preview to re-render, which also
// invalidates all our state
let _ = fut.await;
}
});
}
}
}
Ok(())
}