1// Copyright © SixtyFPS GmbH <info@slint.dev>
2// SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0
3
4// cSpell: ignore resizer
5
6import { Button, ComboBox, HorizontalBox, LineEdit, ListView, Palette, ScrollView, VerticalBox } from "std-widgets.slint";
7import { Api, ComponentItem, DiagnosticSummary, DropMark, LayoutKind, Selection } from "../api.slint";
8import { Resizer } from "../components/resizer.slint";
9import { Group, GroupHeader } from "../components/group.slint";
10import { SelectionPopup } from "../components/selection-popup.slint";
11import { StatusLineApi } from "../components/status-line.slint";
12import { EditorPalette } from "../components/styling.slint";
13
14global PreviewState {
15 out property <length> minimum-preview-size: 16px;
16}
17
18enum SelectionKind {
19 none,
20 select_at,
21 select_up_or_down,
22}
23
24export enum DrawAreaMode {
25 uninitialized,
26 viewing,
27 selecting,
28 outdated,
29}
30
31component SelectionFrame {
32 in property <Selection> selection;
33 in property <bool> interactive: true;
34
35 function pick-selection-color(selection: Selection) -> color {
36 if selection.layout-data != LayoutKind.None {
37 if selection.is-primary {
38 return EditorPalette.layout-element-selection-primary;
39 } else {
40 return EditorPalette.layout-element-selection-secondary;
41 }
42 } else if selection.is-interactive {
43 if selection.is-primary {
44 return EditorPalette.interactive-element-selection-primary;
45 } else {
46 return EditorPalette.interactive-element-selection-secondary;
47 }
48 } else {
49 if selection.is-primary {
50 return EditorPalette.general-element-selection-primary;
51 } else {
52 return EditorPalette.general-element-selection-secondary;
53 }
54 }
55 }
56
57 private property <color> selection-color: pick-selection-color(root.selection);
58
59 x: root.selection.geometry.x;
60 y: root.selection.geometry.y;
61 width: root.selection.geometry.width;
62 height: root.selection.geometry.height;
63
64 property <bool> had-drag-distance: false;
65
66 callback resize(x: length, y: length, width: length, height: length);
67 callback can-move-to(x: length, y: length, mouse-x: length, mouse-y: length) -> bool;
68 callback move-to(x: length, y: length, mouse-x: length, mouse-y: length);
69 callback select-through(x: length, y: length, enter-component: bool, reverse: bool);
70 callback selection-stack-at(x: length, y: length);
71 callback selected-element-delete();
72
73 if !root.interactive || !selection.is-primary: Rectangle {
74 x: 0;
75 y: 0;
76 border-color: root.selection-color;
77 border-width: 1px;
78 }
79
80 if root.interactive && selection.is-primary: Resizer {
81 clicked(x, y, modifiers) => {
82 key-handler.focus();
83 }
84
85 double-clicked(x, y, modifiers) => {
86 root.select-through(root.selection.geometry.x + x, root.selection.geometry.y + y, modifiers.control, modifiers.shift);
87 }
88
89 changed has-hover => {
90 if self.has-hover {
91 StatusLineApi.help-text = @tr("<right-click> show selection popup, <double-click> select behind element, <{}> ignores component boundaries", Api.control-key-name);
92 } else {
93 StatusLineApi.help-text = "";
94 }
95 }
96
97 pointer-event(x, y, event) => {
98 if event.button == PointerEventButton.right && event.kind == PointerEventKind.down {
99 root.selection-stack-at(root.selection.geometry.x + x, root.selection.geometry.y + y);
100 }
101 }
102
103 is-moveable: root.selection.is-moveable;
104 is-resizable: root.selection.is-resizable;
105
106 x-position: root.x;
107 y-position: root.y;
108
109 color: root.selection-color;
110 x: 0;
111 y: 0;
112 width: root.width;
113 height: root.height;
114
115 resize(x, y, w, h, done) => {
116 root.x = x;
117 root.y = y;
118 root.width = w;
119 root.height = h;
120 if done {
121 root.resize(x, y, w, h);
122 }
123 }
124
125 can-move-to(x, y, mx, my) => {
126 root.had-drag-distance = abs((root.x - x) / 1px) > 8 || abs((root.y - y) / 1px) > 8 || root.had-drag-distance;
127
128 if root.had-drag-distance {
129 root.x = x;
130 root.y = y;
131 return root.can-move-to(x, y, mx, my);
132 } else {
133 return false;
134 }
135 }
136
137 move-to(x, y, mx, my) => {
138 root.had-drag-distance = abs((root.x - x) / 1px) > 8 || abs((root.y - y) / 1px) > 8 || root.had-drag-distance;
139
140 if root.had-drag-distance {
141 root.x = x;
142 root.y = y;
143 root.move-to(x, y, mx, my);
144 }
145 root.had-drag-distance = false;
146 }
147
148 inner := Rectangle {
149 border-color: root.selection-color;
150 border-width: 1px;
151 background: parent.resizing || parent.moving ? root.selection-color.with-alpha(0.5) : root.selection-color.with-alpha(0.0);
152 }
153
154 key-handler := FocusScope {
155 enabled <=> root.interactive;
156
157 init => {
158 self.focus();
159 }
160
161 key-pressed(event) => {
162 if event.text == Key.Delete {
163 Api.selected-element-delete();
164 return accept;
165 }
166 reject
167 }
168 }
169 }
170
171 // Size label:
172 if selection.is-resizable && root.selection.is-primary && interactive: Rectangle {
173 x: (root.width - label.width) * 0.5;
174 y: root.height + 3px;
175 width: label.width;
176 height: label.height;
177
178 label := Rectangle {
179 background: root.selection-color;
180 width: label-text.width * 1.2;
181 height: label-text.height * 1.2;
182 label-text := Text {
183 color: Colors.white;
184 text: Math.round(root.width / 1px) + "x" + Math.round(root.height / 1px);
185 }
186 }
187 }
188}
189
190export component PreviewView {
191 property <[Selection]> selections <=> Api.selections;
192 in property <ComponentItem> visible-component;
193 property <DiagnosticSummary> diagnostic-summary <=> Api.diagnostic-summary;
194 out property <bool> preview-is-current: self.diagnostic-summary != DiagnosticSummary.Errors;
195
196 property <DropMark> drop-mark <=> Api.drop-mark;
197 property <component-factory> preview-area <=> Api.preview-area;
198 in-out property <bool> select-mode: false;
199
200 out property <bool> preview-visible: preview-area-container.has-component;
201 out property <DrawAreaMode> mode: uninitialized;
202
203 out property <length> preview-area-position-x: preview-area-container.absolute-position.x;
204 out property <length> preview-area-position-y: preview-area-container.absolute-position.y;
205 out property <length> preview-area-width: preview-visible ? preview-area-container.width : 0px;
206 out property <length> preview-area-height: preview-visible ? preview-area-container.height : 0px;
207
208 preferred-height: max(max(preview-area-container.preferred-height, preview-area-container.min-height) + 2 * scroll-view.border, 10 * scroll-view.border);
209 preferred-width: max(max(preview-area-container.preferred-width, preview-area-container.min-width) + 2 * scroll-view.border, 10 * scroll-view.border);
210
211 changed diagnostic-summary => {
212 if self.diagnostic-summary == DiagnosticSummary.NothingDetected {
213 StatusLineApi.help-text = @tr("");
214 } else if self.diagnostic-summary == DiagnosticSummary.Warnings {
215 StatusLineApi.help-text = @tr("Check your text editor for warnings");
216 }
217 if self.diagnostic-summary == DiagnosticSummary.Errors {
218 StatusLineApi.help-text = @tr("Check your text editor for errors");
219 }
220 }
221
222 scroll-view := ScrollView {
223 out property <length> border: 30px;
224
225 width: 100%;
226 height: 100%;
227 viewport-width: drawing-rect.width;
228 viewport-height: drawing-rect.height;
229
230 drawing-rect := Rectangle {
231 width: max(scroll-view.visible-width, main-resizer.width + scroll-view.border);
232 height: max(scroll-view.visible-height, main-resizer.height + scroll-view.border);
233 background: Palette.background;
234 Image {
235 width: 100%;
236 height: 100%;
237 source: @image-url("../assets/background.svg");
238 vertical-tiling: repeat;
239 horizontal-tiling: repeat;
240 vertical-alignment: top;
241 horizontal-alignment: left;
242 colorize: Palette.alternate-background;
243 }
244
245 unselect-area := TouchArea {
246 clicked => {
247 Api.unselect();
248 }
249 mouse-cursor: root.mode == DrawAreaMode.selecting ? MouseCursor.crosshair : MouseCursor.default;
250
251 changed has-hover => {
252 if self.has-hover {
253 StatusLineApi.help-text = "<click> unselect";
254 } else {
255 StatusLineApi.help-text = "";
256 }
257 }
258 }
259
260 unselect-area-blocker:= TouchArea {
261 x: content-border.x;
262 y: content-border.y;
263 width: content-border.width;
264 height: content-border.height;
265
266 mouse-cursor: root.mode == DrawAreaMode.selecting ? MouseCursor.crosshair : MouseCursor.default;
267 }
268
269 content-border := Rectangle {
270 x: main-resizer.x + (main-resizer.width - self.width) / 2;
271 y: main-resizer.y + (main-resizer.height - self.height) / 2;
272 width: main-resizer.width + 2 * self.border-width;
273 height: main-resizer.height + 2 * self.border-width;
274 border-width: 1px;
275 border-color: Palette.border;
276 }
277
278 main-resizer := Resizer {
279 private property <component-factory> component-factory <=> preview-area-container.component-factory;
280 private property <length> target-width;
281 private property <length> target-height;
282
283 color: root.select-mode ? Colors.black : Colors.transparent;
284 fill-color: root.select-mode ? Colors.white : Colors.transparent;
285
286 is-moveable: false;
287 is-resizable <=> preview-area-container.is-resizable;
288
289 x-position: parent.x;
290 y-position: parent.y;
291
292 resize(_, _, w, h) => {
293 resize-to-preview-constraints(w, h);
294 }
295
296 width: preview-area-container.width;
297 height: preview-area-container.height;
298
299 changed component-factory => {
300 if Api.resize-to-preferred-size {
301 self.target-width = preview-area-container.preferred-width.abs() < 0.5px ? drawing-rect.width - scroll-view.border : preview-area-container.preferred-width;
302 self.target-height = preview-area-container.preferred-height.abs() < 0.5px ? drawing-rect.height - scroll-view.border : preview-area-container.preferred-height;
303
304 resize-to-preview-constraints(self.target-width, self.target-height);
305 } else {
306 resize-to-preview-constraints(preview-area-container.width, preview-area-container.height);
307 }
308
309 Api.resize-to-preferred-size = false;
310
311 if Api.focus-previewed-element {
312 preview-area-container.focus();
313 }
314
315 Api.focus-previewed-element = false;
316 }
317
318 function resize-to-preview-constraints(width: length, height: length) {
319 preview-area-container.width = clamp(width, max(preview-area-container.min-width, PreviewState.minimum-preview-size), max(preview-area-container.max-width, PreviewState.minimum-preview-size));
320 preview-area-container.height = clamp(height, max(preview-area-container.min-height, PreviewState.minimum-preview-size), max(preview-area-container.max-height, PreviewState.minimum-preview-size));
321 }
322
323 Rectangle {
324 clip: true;
325
326 HorizontalLayout {
327 preview-area-container := ComponentContainer {
328 property <bool> is-resizable: (self.min-width != self.max-width || self.min-height != self.max-height) && self.has-component;
329
330 component-factory: root.preview-area;
331
332 // The width and the height can't depend on the layout info of the inner item otherwise this would
333 // cause a recursion if this happens (#3989)
334 // Instead, we use a init function to initialize
335 width: 0px;
336 height: 0px;
337
338 init => {
339 self.width = max(self.preferred-width, self.min-width);
340 self.height = max(self.preferred-height, self.min-height);
341 }
342
343 changed min-width => {
344 main-resizer.resize-to-preview-constraints(self.width, self.height);
345 }
346
347 changed max-width => {
348 main-resizer.resize-to-preview-constraints(self.width, self.height);
349 }
350
351 changed width => {
352 Api.reselect();
353 }
354
355 changed height => {
356 Api.reselect();
357 }
358 }
359 }
360 }
361
362 selection-area := TouchArea {
363 private property <length> selection-x: 0px;
364 private property <length> selection-y: 0px;
365 private property <SelectionKind> selection-kind: SelectionKind.none;
366
367 clicked => {
368 self.selection-x = self.pressed-x;
369 self.selection-y = self.pressed-y;
370 self.selection-kind = SelectionKind.select-at;
371 }
372 double-clicked => {
373 self.selection-x = self.pressed-x;
374 self.selection-y = self.pressed-y;
375 self.selection-kind = SelectionKind.select-up-or-down;
376 }
377
378 pointer-event(event) => {
379 // This needs to fire AFTER clicked and double-clicked to work :-/
380 if (event.kind == PointerEventKind.up && event.button == PointerEventButton.left) {
381 if (self.selection-kind == SelectionKind.select_up_or_down) {
382 Api.select-behind(self.selection-x, self.selection-y, event.modifiers.control, event.modifiers.shift);
383 } else if (self.selection-kind == SelectionKind.select-at) {
384 Api.select-at(self.selection-x, self.selection-y, event.modifiers.control);
385 }
386 } else if (event.kind == PointerEventKind.down && event.button == PointerEventButton.right) {
387 self.selection-x = self.mouse-x;
388 self.selection-y = self.mouse-y;
389 self.selection-kind = SelectionKind.select-at;
390
391 selection-popup.show-selection-stack(self.selection-x, self.selection-y);
392 } else if (event.kind == PointerEventKind.up && event.button == PointerEventButton.right) {
393 self.selection-kind = SelectionKind.none;
394 }
395 self.selection-kind = SelectionKind.none;
396 }
397
398 mouse-cursor: crosshair;
399 enabled: root.mode == DrawAreaMode.selecting;
400
401 changed has-hover => {
402 if self.has-hover && self.enabled {
403 StatusLineApi.help-text = @tr("<click> select element in current component, <right-click> to select interactively, <{}-click> to select an element in any component", Api.control-key-name);
404 } else {
405 StatusLineApi.help-text = "";
406 }
407 }
408 }
409
410 selection-popup := SelectionPopup {
411 preview-width: root.preview-area-width;
412 preview-height: root.preview-area-height;
413 x: 0px;
414 y: 0px;
415 max-popup-height: root.height * 0.9;
416 }
417
418 selection-display-area := Rectangle {
419 for s in root.selections: SelectionFrame {
420 interactive: root.mode == DrawAreaMode.selecting;
421 selection: s;
422 resize(x, y, w, h) => {
423 Api.selected-element-resize(x, y, w, h);
424 }
425
426 can-move-to(x, y, mx, my) => {
427 Api.selected-element-can-move-to(x, y, mx, my);
428 }
429
430 move-to(x, y, mx, my) => {
431 Api.selected-element-move(x, y, mx, my);
432 }
433
434 selected-element-delete() => {
435 Api.selected-element-delete();
436 }
437
438 select-through(x, y, c, f) => {
439 Api.select-behind(x, y, c, f);
440 }
441
442 selection-stack-at(x, y) => {
443 selection-popup.show-selection-stack(x, y);
444 }
445 }
446 }
447
448 if drop-mark.x1 >= 0.0 || drop-mark.y1 >= 0.0 || drop-mark.x2 >= 0.0 || drop-mark.y2 >= 0.0: Rectangle {
449 x: drop-mark.x1;
450 y: drop-mark.y1;
451 width: drop-mark.x2 - drop-mark.x1;
452 height: drop-mark.y2 - drop-mark.y1;
453
454 border-color: EditorPalette.drop-mark-foreground;
455 background: EditorPalette.drop-mark-background;
456 }
457 }
458 }
459 }
460
461 states [
462 uninitialized when !preview-area-container.has-component: {
463 root.mode: DrawAreaMode.uninitialized;
464 }
465 error when !root.preview-is-current && preview-area-container.has-component: {
466 root.mode: DrawAreaMode.outdated;
467 }
468 selecting when root.select-mode && root.preview-is-current && preview-area-container.has-component: {
469 root.mode: DrawAreaMode.selecting;
470 }
471 viewing when !root.select-mode && root.preview-is-current && preview-area-container.has-component: {
472 root.mode: DrawAreaMode.viewing;
473 }
474 ]
475}
476