diff --git a/tools/lsp/common.rs b/tools/lsp/common.rs index d45bc5d3f..d8198680f 100644 --- a/tools/lsp/common.rs +++ b/tools/lsp/common.rs @@ -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, component: ComponentAddition }, + UpdateElement { position: VersionedPosition, properties: Vec }, } /// Information on the Element types available diff --git a/tools/lsp/language.rs b/tools/lsp/language.rs index fc04ca5cc..0fbc2f401 100644 --- a/tools/lsp/language.rs +++ b/tools/lsp/language.rs @@ -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, +) -> Result { + 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::*; diff --git a/tools/lsp/language/properties.rs b/tools/lsp/language/properties.rs index 2b002dd19..ab8193852 100644 --- a/tools/lsp/language/properties.rs +++ b/tools/lsp/language/properties.rs @@ -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)> { + 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)>, 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, diff --git a/tools/lsp/main.rs b/tools/lsp/main.rs index 9a934a9e5..9ce2c69c6 100644 --- a/tools/lsp/main.rs +++ b/tools/lsp/main.rs @@ -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::( @@ -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::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(()) } diff --git a/tools/lsp/preview.rs b/tools/lsp/preview.rs index bce0e1202..ecb201f2e 100644 --- a/tools/lsp/preview.rs +++ b/tools/lsp/preview.rs @@ -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::>(); let model = Rc::new(slint::VecModel::from(values)); diff --git a/tools/lsp/preview/ui.rs b/tools/lsp/preview/ui.rs index d8065eb7f..25acab4c7 100644 --- a/tools/lsp/preview/ui.rs +++ b/tools/lsp/preview/ui.rs @@ -45,6 +45,7 @@ pub fn create_ui(style: String, experimental: bool) -> Result 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 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); + } } } } diff --git a/tools/lsp/ui/main.slint b/tools/lsp/ui/main.slint index 418bf234a..8a6b59e5f 100644 --- a/tools/lsp/ui/main.slint +++ b/tools/lsp/ui/main.slint @@ -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(); } diff --git a/tools/lsp/ui/resizer.slint b/tools/lsp/ui/resizer.slint index e160b8461..948d17a49 100644 --- a/tools/lsp/ui/resizer.slint +++ b/tools/lsp/ui/resizer.slint @@ -6,8 +6,17 @@ global ResizeState { } component ResizeHandle inherits Rectangle { - callback resize(/* width */ length, /* height */ length); in property mouse-cursor; + out property resizing <=> ta.pressed; + + callback resize(/* width */ length, /* height */ length, /* done? */ bool); + callback resize-done(/* width */ length, /* height */ length); + + // Internal! + in-out property new-width; + in-out property new-height; + in-out property new-x; + in-out property 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 x-offset: self.mouse-x - self.pressed-x; + private property 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 is-resizable: true; - callback resize(/* width */ length, /* height */ length); + in property x-position; + in property y-position; - property handle-size: ResizeState.handle-size; + in property is-resizable: true; + in property is-moveable: false; + in property color: Colors.black; + out property resizing: n.resizing || ne.resizing || e.resizing || se.resizing || s.resizing || sw.resizing || w.resizing || nw.resizing; + out property moving: resize-area.moving; + out property handle-size: ResizeState.handle-size; + + callback update-geometry(/* x */ length, /* y */ length, /* width */ length, /* height */ length, /* done */ bool); + + // Private! + in-out property top: self.y-position; + in-out property bottom: self.y-position + self.height; + in-out property left: self.x-position; + in-out property right: self.x-position + self.width; resize-area := Rectangle { - width <=> parent.width; - height <=> parent.height; + in-out property moving: false; + + width <=> root.width; + height <=> root.height; @children + + if root.is-moveable: TouchArea { + private property x-offset: self.mouse-x - self.pressed-x; + private property y-offset: self.mouse-y - self.pressed-y; + private property 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; diff --git a/tools/lsp/wasm_main.rs b/tools/lsp/wasm_main.rs index 9b6ddf83d..6904af522 100644 --- a/tools/lsp/wasm_main.rs +++ b/tools/lsp/wasm_main.rs @@ -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::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(()) }