← luna

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).