Commit fea0b66
9
files
+653
added
-22
deleted
@@ -333,7 +333,9 @@ XDG_CONFIG_HOME="$PWD/examples" cargo run -p luna-demo --bin render -- --lang fr
333
333
z przyciskami na krawędziach), `BottomSheet` (dolny panel
334
334
modalny z „grabberem”, przeciąganiem i zamykaniem), `Pager` (karuzela stron z kropkami),
335
335
`PathBar` (okruszki z separatorem `›`, wskaźnikiem `▾` i zwijaniem do `…`), `Entry`
336
- (pole tekstowe: kursor, edycja, opcjonalna ikona wiodąca i czyszczenie) — z `SearchEntry`
336
+ (pole tekstowe: kursor, edycja, **zaznaczanie** i schowek — `select_all`/`copy`/`cut`/`paste`,
337
+ Shift+strzałki/Ctrl+A, opcjonalna ikona wiodąca i czyszczenie; to samo w `TextView`)
338
+ — z `SearchEntry`
337
339
jako jego wyszukiwarkową nakładką (lupka + czyszczenie), `ItemView` (pliki/katalogi w siatce lub
338
340
liście, z zaznaczaniem i przewijaniem) i `ScrollView` (pionowe przewijanie: rolka + „kciuk”)
339
341
oraz `Stepper`, `CheckButton` i `RadioGroup` (krokowiec, pole wyboru, wybór jednokrotny),
@@ -1007,21 +1007,56 @@ fn cycle_language() {
1007
1007
///
1008
1008
/// Zwraca `true`, jeśli klawisz został zużyty (wtedy skróty demo są pomijane).
1009
1009
fn handle_text_key<T: TextInput>(entry: &mut T, ke: &KeyEvent) -> bool {
1010
+ let shift = ke.modifiers.shift;
1011
+ let ctrl = ke.modifiers.ctrl;
1012
+
1013
+ // Ctrl+A — zaznacz cały tekst.
1014
+ if ctrl && ke.keysym == 0x61 {
1015
+ entry.select_all();
1016
+ return true;
1017
+ }
1018
+
1010
1019
// Tekst złożony przez platformę (obsługuje też znaki spoza ASCII).
1011
- if let Some(t) = &ke.text {
1012
- if !t.is_empty() && !t.chars().all(char::is_control) {
1013
- entry.insert_str(t);
1014
- return true;
1020
+ if !ctrl {
1021
+ if let Some(t) = &ke.text {
1022
+ if !t.is_empty() && !t.chars().all(char::is_control) {
1023
+ entry.insert_str(t);
1024
+ return true;
1025
+ }
1015
1026
}
1016
1027
}
1017
1028
match ke.keysym {
1018
1029
0xff08 => entry.backspace(), // BackSpace
1019
1030
0xffff => entry.delete_forward(), // Delete
1020
- 0xff51 => entry.move_left(), // Left
1021
- 0xff53 => entry.move_right(), // Right
1022
- 0xff50 => entry.home(), // Home
1023
- 0xff57 => entry.end(), // End
1024
- 0x20..=0x7e => {
1031
+ 0xff51 => {
1032
+ if shift {
1033
+ entry.move_left_selecting(true)
1034
+ } else {
1035
+ entry.move_left()
1036
+ }
1037
+ }
1038
+ 0xff53 => {
1039
+ if shift {
1040
+ entry.move_right_selecting(true)
1041
+ } else {
1042
+ entry.move_right()
1043
+ }
1044
+ }
1045
+ 0xff50 => {
1046
+ if shift {
1047
+ entry.home_selecting(true)
1048
+ } else {
1049
+ entry.home()
1050
+ }
1051
+ }
1052
+ 0xff57 => {
1053
+ if shift {
1054
+ entry.end_selecting(true)
1055
+ } else {
1056
+ entry.end()
1057
+ }
1058
+ }
1059
+ 0x20..=0x7e if !ctrl => {
1025
1060
if let Some(c) = char::from_u32(ke.keysym) {
1026
1061
entry.insert_char(c);
1027
1062
}
@@ -461,3 +461,57 @@ fn choosing_style_from_menu_sets_style() {
461
461
assert_eq!(settings.style(), Some("nord"));
462
462
assert!(demo.popover.is_none());
463
463
}
464
+
465
+fn key(keysym: u32, text: Option<String>, modifiers: Modifiers) -> Event {
466
+ Event::Key(KeyEvent {
467
+ keysym,
468
+ text,
469
+ state: ButtonState::Pressed,
470
+ modifiers,
471
+ repeat: false,
472
+ })
473
+}
474
+
475
+#[test]
476
+fn ctrl_a_selects_all_and_typing_replaces() {
477
+ let mut settings = ThemeSettings::default();
478
+ let mut demo = Demo::default();
479
+ let theme = settings.resolve(false);
480
+ let text = TextEngine::system();
481
+
482
+ // Klik w pole wyszukiwania → fokus.
483
+ let search_rect = layout(&settings, &demo).search;
484
+ ui::handle_events(
485
+ &mut settings,
486
+ &[press(search_rect.center())],
487
+ size(),
488
+ &theme,
489
+ &text,
490
+ &mut demo,
491
+ );
492
+ assert!(demo.search.is_focused());
493
+
494
+ // Wpisanie „abc”.
495
+ for c in ['a', 'b', 'c'] {
496
+ let e = key(c as u32, Some(c.to_string()), Modifiers::NONE);
497
+ ui::handle_events(&mut settings, &[e], size(), &theme, &text, &mut demo);
498
+ }
499
+ assert_eq!(demo.search.text(), "abc");
500
+
501
+ // Ctrl+A zaznacza wszystko…
502
+ let ctrl_a = key(
503
+ 0x61,
504
+ None,
505
+ Modifiers {
506
+ ctrl: true,
507
+ ..Modifiers::NONE
508
+ },
509
+ );
510
+ ui::handle_events(&mut settings, &[ctrl_a], size(), &theme, &text, &mut demo);
511
+ assert_eq!(demo.search.selected_text(), Some("abc"));
512
+
513
+ // …a wpisanie znaku zastępuje zaznaczenie.
514
+ let x = key(0x58, Some("X".to_owned()), Modifiers::NONE);
515
+ ui::handle_events(&mut settings, &[x], size(), &theme, &text, &mut demo);
516
+ assert_eq!(demo.search.text(), "X");
517
+}
@@ -105,6 +105,42 @@ impl PasswordEntry {
105
105
self.entry.end();
106
106
}
107
107
108
+ pub fn selection(&self) -> Option<(usize, usize)> {
109
+ self.entry.selection()
110
+ }
111
+
112
+ pub fn has_selection(&self) -> bool {
113
+ self.entry.has_selection()
114
+ }
115
+
116
+ pub fn selected_text(&self) -> Option<&str> {
117
+ self.entry.selected_text()
118
+ }
119
+
120
+ pub fn set_selection(&mut self, start: usize, end: usize) {
121
+ self.entry.set_selection(start, end);
122
+ }
123
+
124
+ pub fn select_all(&mut self) {
125
+ self.entry.select_all();
126
+ }
127
+
128
+ pub fn clear_selection(&mut self) {
129
+ self.entry.clear_selection();
130
+ }
131
+
132
+ pub fn copy(&self) -> Option<String> {
133
+ self.entry.copy()
134
+ }
135
+
136
+ pub fn cut(&mut self) -> Option<String> {
137
+ self.entry.cut()
138
+ }
139
+
140
+ pub fn paste(&mut self, s: &str) {
141
+ self.entry.paste(s);
142
+ }
143
+
108
144
pub fn height(&self, theme: &Theme) -> f64 {
109
145
self.entry.height(theme)
110
146
}
@@ -196,6 +232,24 @@ impl TextInput for PasswordEntry {
196
232
fn end(&mut self) {
197
233
PasswordEntry::end(self);
198
234
}
235
+ fn move_left_selecting(&mut self, shift: bool) {
236
+ self.entry.move_left_selecting(shift);
237
+ }
238
+ fn move_right_selecting(&mut self, shift: bool) {
239
+ self.entry.move_right_selecting(shift);
240
+ }
241
+ fn home_selecting(&mut self, shift: bool) {
242
+ self.entry.home_selecting(shift);
243
+ }
244
+ fn end_selecting(&mut self, shift: bool) {
245
+ self.entry.end_selecting(shift);
246
+ }
247
+ fn select_all(&mut self) {
248
+ self.entry.select_all();
249
+ }
250
+ fn clear_selection(&mut self) {
251
+ self.entry.clear_selection();
252
+ }
199
253
}
200
254
201
255
/// Etykieta edytowalna: pokazuje tekst, a po wejściu w edycję zamienia się w [`Entry`].
@@ -36,6 +36,8 @@ pub struct Entry {
36
36
peek: bool,
37
37
/// Miejsce zarezerwowane po prawej (np. na ikonę „oka”).
38
38
trailing: f64,
39
+ /// Kotwica zaznaczenia (bajt); `None` = brak zaznaczenia.
40
+ anchor: Option<usize>,
39
41
}
40
42
41
43
impl Entry {
@@ -109,6 +111,7 @@ impl Entry {
109
111
pub fn set_text(&mut self, text: impl Into<String>) {
110
112
self.text = text.into();
111
113
self.cursor = self.text.len();
114
+ self.anchor = None;
112
115
}
113
116
114
117
pub fn set_focused(&mut self, focused: bool) {
@@ -126,10 +129,82 @@ impl Entry {
126
129
pub fn clear(&mut self) {
127
130
self.text.clear();
128
131
self.cursor = 0;
132
+ self.anchor = None;
129
133
}
130
134
131
- /// Wstawia tekst w miejscu kursora.
135
+ /// Zakres zaznaczenia `(start, end)` w bajtach (albo `None`).
136
+ pub fn selection(&self) -> Option<(usize, usize)> {
137
+ let a = self.anchor?;
138
+ if a == self.cursor {
139
+ None
140
+ } else {
141
+ Some((a.min(self.cursor), a.max(self.cursor)))
142
+ }
143
+ }
144
+
145
+ pub fn has_selection(&self) -> bool {
146
+ self.selection().is_some()
147
+ }
148
+
149
+ /// Zaznaczony tekst (albo `None`).
150
+ pub fn selected_text(&self) -> Option<&str> {
151
+ self.selection().map(|(a, b)| &self.text[a..b])
152
+ }
153
+
154
+ /// Ustawia zaznaczenie `start..end` (przycinane do granic znaku); kursor na `end`.
155
+ pub fn set_selection(&mut self, start: usize, end: usize) {
156
+ self.anchor = Some(clamp_boundary(&self.text, start));
157
+ self.cursor = clamp_boundary(&self.text, end);
158
+ }
159
+
160
+ /// Zaznacza cały tekst.
161
+ pub fn select_all(&mut self) {
162
+ self.anchor = Some(0);
163
+ self.cursor = self.text.len();
164
+ }
165
+
166
+ pub fn clear_selection(&mut self) {
167
+ self.anchor = None;
168
+ }
169
+
170
+ /// Zwraca zaznaczenie do schowka aplikacji.
171
+ pub fn copy(&self) -> Option<String> {
172
+ self.selected_text().map(str::to_owned)
173
+ }
174
+
175
+ /// Wycina zaznaczenie (usuwa je i zwraca).
176
+ pub fn cut(&mut self) -> Option<String> {
177
+ let s = self.copy()?;
178
+ self.delete_selection();
179
+ Some(s)
180
+ }
181
+
182
+ /// Wkleja tekst (zastępuje zaznaczenie).
183
+ pub fn paste(&mut self, s: &str) {
184
+ self.insert_str(s);
185
+ }
186
+
187
+ fn delete_selection(&mut self) {
188
+ if let Some((a, b)) = self.selection() {
189
+ self.text.replace_range(a..b, "");
190
+ self.cursor = a;
191
+ }
192
+ self.anchor = None;
193
+ }
194
+
195
+ fn begin_or_clear(&mut self, shift: bool) {
196
+ if shift {
197
+ if self.anchor.is_none() {
198
+ self.anchor = Some(self.cursor);
199
+ }
200
+ } else {
201
+ self.anchor = None;
202
+ }
203
+ }
204
+
205
+ /// Wstawia tekst w miejscu kursora (zastępuje zaznaczenie).
132
206
pub fn insert_str(&mut self, s: &str) {
207
+ self.delete_selection();
133
208
self.text.insert_str(self.cursor, s);
134
209
self.cursor += s.len();
135
210
}
@@ -139,8 +214,12 @@ impl Entry {
139
214
self.insert_str(c.encode_utf8(&mut buf));
140
215
}
141
216
142
- /// Usuwa znak przed kursorem.
217
+ /// Usuwa znak przed kursorem (albo zaznaczenie).
143
218
pub fn backspace(&mut self) {
219
+ if self.has_selection() {
220
+ self.delete_selection();
221
+ return;
222
+ }
144
223
if self.cursor > 0 {
145
224
let prev = prev_boundary(&self.text, self.cursor);
146
225
self.text.replace_range(prev..self.cursor, "");
@@ -148,8 +227,12 @@ impl Entry {
148
227
}
149
228
}
150
229
151
- /// Usuwa znak za kursorem.
230
+ /// Usuwa znak za kursorem (albo zaznaczenie).
152
231
pub fn delete_forward(&mut self) {
232
+ if self.has_selection() {
233
+ self.delete_selection();
234
+ return;
235
+ }
153
236
if self.cursor < self.text.len() {
154
237
let next = next_boundary(&self.text, self.cursor);
155
238
self.text.replace_range(self.cursor..next, "");
@@ -157,18 +240,40 @@ impl Entry {
157
240
}
158
241
159
242
pub fn move_left(&mut self) {
160
- self.cursor = prev_boundary(&self.text, self.cursor);
243
+ self.move_left_selecting(false);
161
244
}
162
245
163
246
pub fn move_right(&mut self) {
247
+ self.move_right_selecting(false);
248
+ }
249
+
250
+ /// Ruch kursora w lewo; z `shift` rozciąga zaznaczenie.
251
+ pub fn move_left_selecting(&mut self, shift: bool) {
252
+ self.begin_or_clear(shift);
253
+ self.cursor = prev_boundary(&self.text, self.cursor);
254
+ }
255
+
256
+ /// Ruch kursora w prawo; z `shift` rozciąga zaznaczenie.
257
+ pub fn move_right_selecting(&mut self, shift: bool) {
258
+ self.begin_or_clear(shift);
164
259
self.cursor = next_boundary(&self.text, self.cursor);
165
260
}
166
261
167
262
pub fn home(&mut self) {
168
- self.cursor = 0;
263
+ self.home_selecting(false);
169
264
}
170
265
171
266
pub fn end(&mut self) {
267
+ self.end_selecting(false);
268
+ }
269
+
270
+ pub fn home_selecting(&mut self, shift: bool) {
271
+ self.begin_or_clear(shift);
272
+ self.cursor = 0;
273
+ }
274
+
275
+ pub fn end_selecting(&mut self, shift: bool) {
276
+ self.begin_or_clear(shift);
172
277
self.cursor = self.text.len();
173
278
}
174
279
@@ -204,15 +309,20 @@ impl Entry {
204
309
}
205
310
}
206
311
207
- /// Prefiks tekstu do kursora w wersji wyświetlanej (do wyliczenia pozycji kursora).
208
- fn display_prefix(&self) -> String {
209
- let upto = &self.text[..self.cursor];
312
+ /// Prefiks tekstu (w wersji wyświetlanej) do granicy `index` — z uwzględnieniem maski.
313
+ fn display_prefix_at(&self, index: usize) -> String {
314
+ let upto = &self.text[..index];
210
315
match (self.mask, self.peek) {
211
316
(Some(ch), false) => ch.to_string().repeat(upto.chars().count()),
212
317
_ => upto.to_string(),
213
318
}
214
319
}
215
320
321
+ /// Prefiks tekstu do kursora w wersji wyświetlanej (do wyliczenia pozycji kursora).
322
+ fn display_prefix(&self) -> String {
323
+ self.display_prefix_at(self.cursor)
324
+ }
325
+
216
326
/// Pozycja (x), od której zaczyna się tekst (za ikoną wiodącą, jeśli jest).
217
327
fn text_x(&self, rect: Rect, theme: &Theme) -> f64 {
218
328
let m = theme.metrics.entry;
@@ -267,6 +377,21 @@ impl Entry {
267
377
(text_right - text_x).max(0.0),
268
378
rect.height(),
269
379
));
380
+ // Podświetlenie zaznaczenia (pod tekstem).
381
+ if let Some((a, b)) = self.selection() {
382
+ let size = theme.typography.body.size;
383
+ let x1 = text_x + ctx.measure(&self.display_prefix_at(a), size, weight::NORMAL);
384
+ let x2 = text_x + ctx.measure(&self.display_prefix_at(b), size, weight::NORMAL);
385
+ ctx.scene.fill_rect(
386
+ Rect::from_xywh(
387
+ x1,
388
+ rect.y() + 5.0,
389
+ (x2 - x1).max(0.0),
390
+ (rect.height() - 10.0).max(0.0),
391
+ ),
392
+ colors.accent.scale_alpha(0.30),
393
+ );
394
+ }
270
395
let display = self.display_text();
271
396
ctx.label_left(
272
397
text_x,
@@ -311,6 +436,16 @@ pub trait TextInput {
311
436
fn move_right(&mut self);
312
437
fn home(&mut self);
313
438
fn end(&mut self);
439
+ /// Ruch w lewo; z `shift` rozciąga zaznaczenie.
440
+ fn move_left_selecting(&mut self, shift: bool);
441
+ /// Ruch w prawo; z `shift` rozciąga zaznaczenie.
442
+ fn move_right_selecting(&mut self, shift: bool);
443
+ fn home_selecting(&mut self, shift: bool);
444
+ fn end_selecting(&mut self, shift: bool);
445
+ /// Zaznacza cały tekst.
446
+ fn select_all(&mut self);
447
+ /// Usuwa zaznaczenie (bez zmiany tekstu).
448
+ fn clear_selection(&mut self);
314
449
}
315
450
316
451
impl TextInput for Entry {
@@ -338,6 +473,24 @@ impl TextInput for Entry {
338
473
fn end(&mut self) {
339
474
Entry::end(self);
340
475
}
476
+ fn move_left_selecting(&mut self, shift: bool) {
477
+ Entry::move_left_selecting(self, shift);
478
+ }
479
+ fn move_right_selecting(&mut self, shift: bool) {
480
+ Entry::move_right_selecting(self, shift);
481
+ }
482
+ fn home_selecting(&mut self, shift: bool) {
483
+ Entry::home_selecting(self, shift);
484
+ }
485
+ fn end_selecting(&mut self, shift: bool) {
486
+ Entry::end_selecting(self, shift);
487
+ }
488
+ fn select_all(&mut self) {
489
+ Entry::select_all(self);
490
+ }
491
+ fn clear_selection(&mut self) {
492
+ Entry::clear_selection(self);
493
+ }
341
494
}
342
495
343
496
fn prev_boundary(s: &str, i: usize) -> usize {
@@ -352,6 +505,15 @@ fn next_boundary(s: &str, i: usize) -> usize {
352
505
s[i..].chars().next().map(|c| i + c.len_utf8()).unwrap_or(i)
353
506
}
354
507
508
+/// Przybliża indeks do najbliższej granicy znaku (w dół) i do długości napisu.
509
+fn clamp_boundary(s: &str, i: usize) -> usize {
510
+ let mut b = i.min(s.len());
511
+ while b > 0 && !s.is_char_boundary(b) {
512
+ b -= 1;
513
+ }
514
+ b
515
+}
516
+
355
517
#[cfg(test)]
356
518
mod tests {
357
519
use super::*;
@@ -416,4 +578,56 @@ mod tests {
416
578
let theme = Theme::light();
417
579
assert_eq!(Entry::new("").height(&theme), theme.metrics.entry.height);
418
580
}
581
+
582
+ #[test]
583
+ fn selection_copy_cut_paste() {
584
+ let mut e = Entry::new("");
585
+ e.set_text("hello world");
586
+ e.set_selection(0, 5);
587
+ assert_eq!(e.selected_text(), Some("hello"));
588
+ assert_eq!(e.copy(), Some("hello".to_owned()));
589
+ // Wklejenie zastępuje zaznaczenie.
590
+ e.paste("HELLO");
591
+ assert_eq!(e.text(), "HELLO world");
592
+ assert!(!e.has_selection());
593
+ // Wycinanie.
594
+ e.set_selection(0, 6);
595
+ assert_eq!(e.cut(), Some("HELLO ".to_owned()));
596
+ assert_eq!(e.text(), "world");
597
+ assert_eq!(e.cursor(), 0);
598
+ }
599
+
600
+ #[test]
601
+ fn shift_extends_selection_and_typing_replaces() {
602
+ let mut e = Entry::new("");
603
+ e.set_text("abc");
604
+ e.home();
605
+ e.move_right_selecting(true);
606
+ e.move_right_selecting(true);
607
+ assert_eq!(e.selected_text(), Some("ab"));
608
+ e.insert_char('X');
609
+ assert_eq!(e.text(), "Xc");
610
+ }
611
+
612
+ #[test]
613
+ fn backspace_deletes_selection() {
614
+ let mut e = Entry::new("");
615
+ e.set_text("abcdef");
616
+ e.set_selection(1, 4);
617
+ e.backspace();
618
+ assert_eq!(e.text(), "aef");
619
+ assert_eq!(e.cursor(), 1);
620
+ }
621
+
622
+ #[test]
623
+ fn select_all_clamps_to_char_boundary() {
624
+ let mut e = Entry::new("");
625
+ e.set_text("jąbko");
626
+ e.select_all();
627
+ assert_eq!(e.selected_text(), Some("jąbko"));
628
+ e.set_selection(2, 2); // środek znaku → przycięte
629
+ assert!(!e.has_selection());
630
+ e.clear_selection();
631
+ assert_eq!(e.selection(), None);
632
+ }
419
633
}
@@ -107,6 +107,62 @@ impl SearchEntry {
107
107
self.entry.clear_at(rect, theme, p)
108
108
}
109
109
110
+ /// Zakres zaznaczenia `(start, end)` w bajtach (albo `None`).
111
+ pub fn selection(&self) -> Option<(usize, usize)> {
112
+ self.entry.selection()
113
+ }
114
+
115
+ pub fn has_selection(&self) -> bool {
116
+ self.entry.has_selection()
117
+ }
118
+
119
+ pub fn selected_text(&self) -> Option<&str> {
120
+ self.entry.selected_text()
121
+ }
122
+
123
+ pub fn set_selection(&mut self, start: usize, end: usize) {
124
+ self.entry.set_selection(start, end);
125
+ }
126
+
127
+ pub fn select_all(&mut self) {
128
+ self.entry.select_all();
129
+ }
130
+
131
+ pub fn clear_selection(&mut self) {
132
+ self.entry.clear_selection();
133
+ }
134
+
135
+ /// Zwraca zaznaczenie do schowka aplikacji.
136
+ pub fn copy(&self) -> Option<String> {
137
+ self.entry.copy()
138
+ }
139
+
140
+ /// Wycina zaznaczenie.
141
+ pub fn cut(&mut self) -> Option<String> {
142
+ self.entry.cut()
143
+ }
144
+
145
+ /// Wkleja tekst (zastępuje zaznaczenie).
146
+ pub fn paste(&mut self, s: &str) {
147
+ self.entry.paste(s);
148
+ }
149
+
150
+ pub fn move_left_selecting(&mut self, shift: bool) {
151
+ self.entry.move_left_selecting(shift);
152
+ }
153
+
154
+ pub fn move_right_selecting(&mut self, shift: bool) {
155
+ self.entry.move_right_selecting(shift);
156
+ }
157
+
158
+ pub fn home_selecting(&mut self, shift: bool) {
159
+ self.entry.home_selecting(shift);
160
+ }
161
+
162
+ pub fn end_selecting(&mut self, shift: bool) {
163
+ self.entry.end_selecting(shift);
164
+ }
165
+
110
166
pub fn paint(&self, ctx: &mut PaintCtx, rect: Rect) {
111
167
self.entry.paint(ctx, rect);
112
168
}
@@ -137,6 +193,24 @@ impl crate::entry::TextInput for SearchEntry {
137
193
fn end(&mut self) {
138
194
SearchEntry::end(self);
139
195
}
196
+ fn move_left_selecting(&mut self, shift: bool) {
197
+ SearchEntry::move_left_selecting(self, shift);
198
+ }
199
+ fn move_right_selecting(&mut self, shift: bool) {
200
+ SearchEntry::move_right_selecting(self, shift);
201
+ }
202
+ fn home_selecting(&mut self, shift: bool) {
203
+ SearchEntry::home_selecting(self, shift);
204
+ }
205
+ fn end_selecting(&mut self, shift: bool) {
206
+ SearchEntry::end_selecting(self, shift);
207
+ }
208
+ fn select_all(&mut self) {
209
+ SearchEntry::select_all(self);
210
+ }
211
+ fn clear_selection(&mut self) {
212
+ SearchEntry::clear_selection(self);
213
+ }
140
214
}
141
215
142
216
#[cfg(test)]
@@ -211,6 +211,24 @@ impl TextInput for SpinButton {
211
211
fn end(&mut self) {
212
212
self.entry.end();
213
213
}
214
+ fn move_left_selecting(&mut self, shift: bool) {
215
+ self.entry.move_left_selecting(shift);
216
+ }
217
+ fn move_right_selecting(&mut self, shift: bool) {
218
+ self.entry.move_right_selecting(shift);
219
+ }
220
+ fn home_selecting(&mut self, shift: bool) {
221
+ self.entry.home_selecting(shift);
222
+ }
223
+ fn end_selecting(&mut self, shift: bool) {
224
+ self.entry.end_selecting(shift);
225
+ }
226
+ fn select_all(&mut self) {
227
+ self.entry.select_all();
228
+ }
229
+ fn clear_selection(&mut self) {
230
+ self.entry.clear_selection();
231
+ }
214
232
}
215
233
216
234
fn format_number(value: f64, digits: usize) -> String {
@@ -20,6 +20,8 @@ pub struct TextView {
20
20
cursor: usize,
21
21
placeholder: String,
22
22
focused: bool,
23
+ /// Kotwica zaznaczenia (bajt); `None` = brak zaznaczenia.
24
+ anchor: Option<usize>,
23
25
}
24
26
25
27
impl TextView {
@@ -53,6 +55,7 @@ impl TextView {
53
55
pub fn set_text(&mut self, text: impl Into<String>) {
54
56
self.text = text.into();
55
57
self.cursor = self.text.len();
58
+ self.anchor = None;
56
59
}
57
60
58
61
pub fn set_placeholder(&mut self, placeholder: impl Into<String>) {
@@ -66,9 +69,79 @@ impl TextView {
66
69
pub fn clear(&mut self) {
67
70
self.text.clear();
68
71
self.cursor = 0;
72
+ self.anchor = None;
73
+ }
74
+
75
+ /// Zakres zaznaczenia `(start, end)` w bajtach (albo `None`).
76
+ pub fn selection(&self) -> Option<(usize, usize)> {
77
+ let a = self.anchor?;
78
+ if a == self.cursor {
79
+ None
80
+ } else {
81
+ Some((a.min(self.cursor), a.max(self.cursor)))
82
+ }
83
+ }
84
+
85
+ pub fn has_selection(&self) -> bool {
86
+ self.selection().is_some()
87
+ }
88
+
89
+ pub fn selected_text(&self) -> Option<&str> {
90
+ self.selection().map(|(a, b)| &self.text[a..b])
91
+ }
92
+
93
+ /// Ustawia zaznaczenie `start..end` (przycinane do granic znaku).
94
+ pub fn set_selection(&mut self, start: usize, end: usize) {
95
+ self.anchor = Some(clamp_boundary(&self.text, start));
96
+ self.cursor = clamp_boundary(&self.text, end);
97
+ }
98
+
99
+ pub fn select_all(&mut self) {
100
+ self.anchor = Some(0);
101
+ self.cursor = self.text.len();
102
+ }
103
+
104
+ pub fn clear_selection(&mut self) {
105
+ self.anchor = None;
106
+ }
107
+
108
+ /// Zwraca zaznaczenie do schowka aplikacji.
109
+ pub fn copy(&self) -> Option<String> {
110
+ self.selected_text().map(str::to_owned)
111
+ }
112
+
113
+ /// Wycina zaznaczenie.
114
+ pub fn cut(&mut self) -> Option<String> {
115
+ let s = self.copy()?;
116
+ self.delete_selection();
117
+ Some(s)
118
+ }
119
+
120
+ /// Wkleja tekst (zastępuje zaznaczenie).
121
+ pub fn paste(&mut self, s: &str) {
122
+ self.insert_str(s);
123
+ }
124
+
125
+ fn delete_selection(&mut self) {
126
+ if let Some((a, b)) = self.selection() {
127
+ self.text.replace_range(a..b, "");
128
+ self.cursor = a;
129
+ }
130
+ self.anchor = None;
131
+ }
132
+
133
+ fn begin_or_clear(&mut self, shift: bool) {
134
+ if shift {
135
+ if self.anchor.is_none() {
136
+ self.anchor = Some(self.cursor);
137
+ }
138
+ } else {
139
+ self.anchor = None;
140
+ }
69
141
}
70
142
71
143
pub fn insert_str(&mut self, s: &str) {
144
+ self.delete_selection();
72
145
self.text.insert_str(self.cursor, s);
73
146
self.cursor += s.len();
74
147
}
@@ -84,6 +157,10 @@ impl TextView {
84
157
}
85
158
86
159
pub fn backspace(&mut self) {
160
+ if self.has_selection() {
161
+ self.delete_selection();
162
+ return;
163
+ }
87
164
if self.cursor > 0 {
88
165
let prev = prev_boundary(&self.text, self.cursor);
89
166
self.text.replace_range(prev..self.cursor, "");
@@ -92,6 +169,10 @@ impl TextView {
92
169
}
93
170
94
171
pub fn delete_forward(&mut self) {
172
+ if self.has_selection() {
173
+ self.delete_selection();
174
+ return;
175
+ }
95
176
if self.cursor < self.text.len() {
96
177
let next = next_boundary(&self.text, self.cursor);
97
178
self.text.replace_range(self.cursor..next, "");
@@ -99,23 +180,53 @@ impl TextView {
99
180
}
100
181
101
182
pub fn move_left(&mut self) {
102
- self.cursor = prev_boundary(&self.text, self.cursor);
183
+ self.move_left_selecting(false);
103
184
}
104
185
105
186
pub fn move_right(&mut self) {
187
+ self.move_right_selecting(false);
188
+ }
189
+
190
+ pub fn move_left_selecting(&mut self, shift: bool) {
191
+ self.begin_or_clear(shift);
192
+ self.cursor = prev_boundary(&self.text, self.cursor);
193
+ }
194
+
195
+ pub fn move_right_selecting(&mut self, shift: bool) {
196
+ self.begin_or_clear(shift);
106
197
self.cursor = next_boundary(&self.text, self.cursor);
107
198
}
108
199
109
200
pub fn home(&mut self) {
110
- self.cursor = self.line_start(self.cursor_line());
201
+ self.home_selecting(false);
111
202
}
112
203
113
204
pub fn end(&mut self) {
205
+ self.end_selecting(false);
206
+ }
207
+
208
+ pub fn home_selecting(&mut self, shift: bool) {
209
+ self.begin_or_clear(shift);
210
+ self.cursor = self.line_start(self.cursor_line());
211
+ }
212
+
213
+ pub fn end_selecting(&mut self, shift: bool) {
214
+ self.begin_or_clear(shift);
114
215
self.cursor = self.line_end(self.cursor_line());
115
216
}
116
217
117
218
/// Przechodzi wiersz w górę, zachowując kolumnę (o ile się mieści).
118
219
pub fn move_up(&mut self) {
220
+ self.move_up_selecting(false);
221
+ }
222
+
223
+ /// Przechodzi wiersz w dół, zachowując kolumnę (o ile się mieści).
224
+ pub fn move_down(&mut self) {
225
+ self.move_down_selecting(false);
226
+ }
227
+
228
+ pub fn move_up_selecting(&mut self, shift: bool) {
229
+ self.begin_or_clear(shift);
119
230
let line = self.cursor_line();
120
231
if line == 0 {
121
232
self.cursor = 0;
@@ -127,8 +238,8 @@ impl TextView {
127
238
self.cursor = start + byte_at_char(&self.text[start..end], col);
128
239
}
129
240
130
- /// Przechodzi wiersz w dół, zachowując kolumnę (o ile się mieści).
131
- pub fn move_down(&mut self) {
241
+ pub fn move_down_selecting(&mut self, shift: bool) {
242
+ self.begin_or_clear(shift);
132
243
let line = self.cursor_line();
133
244
if line + 1 >= self.line_count() {
134
245
self.cursor = self.text.len();
@@ -222,6 +333,30 @@ impl TextView {
222
333
colors.dim_text,
223
334
);
224
335
} else {
336
+ // Podświetlenie zaznaczenia (per wiersz).
337
+ if let Some((a, b)) = self.selection() {
338
+ let starts = self.line_starts();
339
+ for (i, &start) in starts.iter().enumerate() {
340
+ let end = if i + 1 < starts.len() {
341
+ starts[i + 1] - 1
342
+ } else {
343
+ self.text.len()
344
+ };
345
+ let lo = a.max(start);
346
+ let hi = b.min(end);
347
+ if lo < hi {
348
+ let y = inner.y() + i as f64 * lh;
349
+ let x1 =
350
+ inner.x() + ctx.measure(&self.text[start..lo], body, weight::NORMAL);
351
+ let x2 =
352
+ inner.x() + ctx.measure(&self.text[start..hi], body, weight::NORMAL);
353
+ ctx.scene.fill_rect(
354
+ Rect::from_xywh(x1, y + 2.0, (x2 - x1).max(0.0), (lh - 4.0).max(0.0)),
355
+ colors.accent.scale_alpha(0.30),
356
+ );
357
+ }
358
+ }
359
+ }
225
360
for (i, line) in self.lines().enumerate() {
226
361
let y = inner.y() + i as f64 * lh + lh / 2.0;
227
362
if y - lh / 2.0 > inner.bottom() {
@@ -262,6 +397,15 @@ fn byte_at_char(s: &str, col: usize) -> usize {
262
397
s.char_indices().nth(col).map(|(i, _)| i).unwrap_or(s.len())
263
398
}
264
399
400
+/// Przybliża indeks do najbliższej granicy znaku (w dół) i do długości napisu.
401
+fn clamp_boundary(s: &str, i: usize) -> usize {
402
+ let mut b = i.min(s.len());
403
+ while b > 0 && !s.is_char_boundary(b) {
404
+ b -= 1;
405
+ }
406
+ b
407
+}
408
+
265
409
#[cfg(test)]
266
410
mod tests {
267
411
use super::*;
@@ -329,4 +473,37 @@ mod tests {
329
473
3.0 * theme.typography.body.line_height
330
474
);
331
475
}
476
+
477
+ #[test]
478
+ fn multiline_selection_and_editing() {
479
+ let mut t = TextView::new("");
480
+ t.set_text("one\ntwo\nthree");
481
+ // Zaznacz od końca "one" do początku "three".
482
+ t.set_selection(3, 8);
483
+ assert_eq!(t.selected_text(), Some("\ntwo\n"));
484
+ assert_eq!(t.copy(), Some("\ntwo\n".to_owned()));
485
+ t.paste(" | ");
486
+ assert_eq!(t.text(), "one | three");
487
+ assert!(!t.has_selection());
488
+ }
489
+
490
+ #[test]
491
+ fn shift_selects_by_lines() {
492
+ let mut t = TextView::new("");
493
+ t.set_text("ab\ncd");
494
+ t.home();
495
+ t.move_up_selecting(true); // brak wiersza wyżej → kursor na 0, kotwica wcześniej
496
+ t.move_right_selecting(true);
497
+ assert!(t.has_selection());
498
+ }
499
+
500
+ #[test]
501
+ fn backspace_removes_selection() {
502
+ let mut t = TextView::new("");
503
+ t.set_text("abcdef");
504
+ t.set_selection(1, 4);
505
+ t.backspace();
506
+ assert_eq!(t.text(), "aef");
507
+ assert_eq!(t.cursor(), 1);
508
+ }
332
509
}
@@ -62,6 +62,9 @@ a na końcu odtworzył realne aplikacje środowiska Luna z makiet.
62
62
Tokeny metryk kontenerów: `Theme::metrics.container`. Animacje: `libluna-core` `motion`
63
63
(`Easing`, `lerp`, `Animation`), użyte w `Stack` (przejścia `StackTransition`), `Revealer`,
64
64
`Toast`, `BottomSheet` i `Pager` (sterowane `advance(dt, …)`).
65
+- **Edycja tekstu**: `Entry`/`SearchEntry`/`PasswordEntry`/`TextView` mają **zaznaczanie**
66
+ (`set_selection`, `select_all`, zakres, Shift+strzałki/Ctrl+A) oraz operacje schowka
67
+ (`copy`/`cut`/`paste`); `TextInput` rozszerzono o metody zaznaczania.
65
68
- `libluna-platform`: realne akcje okna z `Frame` (`close`/`minimize`/`toggle_maximize`).
66
69
**Klatki animacji**: `Frame::delta_time()` + `Frame::request_frame()` (płynne ~60 Hz, gdy trwa
67
70
animacja; bez tego okno odświeża się tylko na zdarzenia).