🔒 Repository is read-only – file editing is disabled.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633
//! `Entry` — pole tekstowe (jednowierszowe) z kursorem, opcjonalną ikoną wiodącą i czyszczeniem.
//!
//! Widget immediate: trzyma treść i pozycję kursora (indeks bajtowy, zawsze na granicy znaku).
//! Aplikacja dostarcza zdarzenia klawiatury jako wywołania metod ([`Entry::insert_str`],
//! [`Entry::backspace`], [`Entry::move_left`]…) i sama decyduje o fokusie ([`Entry::set_focused`]).
//!
//! [`SearchEntry`](crate::SearchEntry) to cienka nakładka: `Entry` z ikoną lupy i czyszczeniem.
use libluna_core::style::weight;
use libluna_core::{Point, Rect, Theme};
use crate::ctx::PaintCtx;
use crate::icon;
/// Ikona wiodąca pola (przed tekstem).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EntryIcon {
/// Lupka (pola wyszukiwania).
Search,
}
/// Pole tekstowe.
#[derive(Debug, Clone, Default)]
pub struct Entry {
text: String,
/// Pozycja kursora jako indeks bajtowy (zawsze na granicy znaku).
cursor: usize,
placeholder: String,
leading: Option<EntryIcon>,
clearable: bool,
focused: bool,
hover_clear: bool,
/// Znak maskujący (np. `•`); `None` gdy tekst widoczny.
mask: Option<char>,
/// Podgląd hasła („oko”): pokazuje jawny tekst mimo maski.
peek: bool,
/// Miejsce zarezerwowane po prawej (np. na ikonę „oka”).
trailing: f64,
/// Kotwica zaznaczenia (bajt); `None` = brak zaznaczenia.
anchor: Option<usize>,
}
impl Entry {
pub fn new(placeholder: impl Into<String>) -> Self {
Self {
placeholder: placeholder.into(),
..Self::default()
}
}
/// Dodaje ikonę wiodącą (np. lupkę).
pub fn leading(mut self, icon: EntryIcon) -> Self {
self.leading = Some(icon);
self
}
/// Włącza przycisk czyszczenia („×”) widoczny, gdy pole niepuste.
pub fn clearable(mut self, clearable: bool) -> Self {
self.clearable = clearable;
self
}
/// Maskuje tekst znakiem `mask` (np. `•`) — pola haseł.
pub fn masked(mut self, mask: char) -> Self {
self.mask = Some(mask);
self
}
pub fn set_masked(&mut self, mask: Option<char>) {
self.mask = mask;
}
pub fn is_masked(&self) -> bool {
self.mask.is_some()
}
/// Pokazuje jawny tekst mimo maski (podgląd hasła).
pub fn set_peek(&mut self, peek: bool) {
self.peek = peek;
}
pub fn is_peek(&self) -> bool {
self.peek
}
/// Rezerwuje miejsce po prawej stronie (np. na ikonę „oka”).
pub fn set_trailing_space(&mut self, space: f64) {
self.trailing = space.max(0.0);
}
pub fn trailing_space(&self) -> f64 {
self.trailing
}
pub fn text(&self) -> &str {
&self.text
}
pub fn is_empty(&self) -> bool {
self.text.is_empty()
}
pub fn cursor(&self) -> usize {
self.cursor
}
pub fn is_focused(&self) -> bool {
self.focused
}
pub fn set_text(&mut self, text: impl Into<String>) {
self.text = text.into();
self.cursor = self.text.len();
self.anchor = None;
}
pub fn set_focused(&mut self, focused: bool) {
self.focused = focused;
}
pub fn set_placeholder(&mut self, placeholder: impl Into<String>) {
self.placeholder = placeholder.into();
}
pub fn set_hover_clear(&mut self, hovered: bool) {
self.hover_clear = hovered;
}
pub fn clear(&mut self) {
self.text.clear();
self.cursor = 0;
self.anchor = None;
}
/// Zakres zaznaczenia `(start, end)` w bajtach (albo `None`).
pub fn selection(&self) -> Option<(usize, usize)> {
let a = self.anchor?;
if a == self.cursor {
None
} else {
Some((a.min(self.cursor), a.max(self.cursor)))
}
}
pub fn has_selection(&self) -> bool {
self.selection().is_some()
}
/// Zaznaczony tekst (albo `None`).
pub fn selected_text(&self) -> Option<&str> {
self.selection().map(|(a, b)| &self.text[a..b])
}
/// Ustawia zaznaczenie `start..end` (przycinane do granic znaku); kursor na `end`.
pub fn set_selection(&mut self, start: usize, end: usize) {
self.anchor = Some(clamp_boundary(&self.text, start));
self.cursor = clamp_boundary(&self.text, end);
}
/// Zaznacza cały tekst.
pub fn select_all(&mut self) {
self.anchor = Some(0);
self.cursor = self.text.len();
}
pub fn clear_selection(&mut self) {
self.anchor = None;
}
/// Zwraca zaznaczenie do schowka aplikacji.
pub fn copy(&self) -> Option<String> {
self.selected_text().map(str::to_owned)
}
/// Wycina zaznaczenie (usuwa je i zwraca).
pub fn cut(&mut self) -> Option<String> {
let s = self.copy()?;
self.delete_selection();
Some(s)
}
/// Wkleja tekst (zastępuje zaznaczenie).
pub fn paste(&mut self, s: &str) {
self.insert_str(s);
}
fn delete_selection(&mut self) {
if let Some((a, b)) = self.selection() {
self.text.replace_range(a..b, "");
self.cursor = a;
}
self.anchor = None;
}
fn begin_or_clear(&mut self, shift: bool) {
if shift {
if self.anchor.is_none() {
self.anchor = Some(self.cursor);
}
} else {
self.anchor = None;
}
}
/// Wstawia tekst w miejscu kursora (zastępuje zaznaczenie).
pub fn insert_str(&mut self, s: &str) {
self.delete_selection();
self.text.insert_str(self.cursor, s);
self.cursor += s.len();
}
pub fn insert_char(&mut self, c: char) {
let mut buf = [0u8; 4];
self.insert_str(c.encode_utf8(&mut buf));
}
/// Usuwa znak przed kursorem (albo zaznaczenie).
pub fn backspace(&mut self) {
if self.has_selection() {
self.delete_selection();
return;
}
if self.cursor > 0 {
let prev = prev_boundary(&self.text, self.cursor);
self.text.replace_range(prev..self.cursor, "");
self.cursor = prev;
}
}
/// Usuwa znak za kursorem (albo zaznaczenie).
pub fn delete_forward(&mut self) {
if self.has_selection() {
self.delete_selection();
return;
}
if self.cursor < self.text.len() {
let next = next_boundary(&self.text, self.cursor);
self.text.replace_range(self.cursor..next, "");
}
}
pub fn move_left(&mut self) {
self.move_left_selecting(false);
}
pub fn move_right(&mut self) {
self.move_right_selecting(false);
}
/// Ruch kursora w lewo; z `shift` rozciąga zaznaczenie.
pub fn move_left_selecting(&mut self, shift: bool) {
self.begin_or_clear(shift);
self.cursor = prev_boundary(&self.text, self.cursor);
}
/// Ruch kursora w prawo; z `shift` rozciąga zaznaczenie.
pub fn move_right_selecting(&mut self, shift: bool) {
self.begin_or_clear(shift);
self.cursor = next_boundary(&self.text, self.cursor);
}
pub fn home(&mut self) {
self.home_selecting(false);
}
pub fn end(&mut self) {
self.end_selecting(false);
}
pub fn home_selecting(&mut self, shift: bool) {
self.begin_or_clear(shift);
self.cursor = 0;
}
pub fn end_selecting(&mut self, shift: bool) {
self.begin_or_clear(shift);
self.cursor = self.text.len();
}
pub fn height(&self, theme: &Theme) -> f64 {
theme.metrics.entry.height
}
pub fn contains(&self, rect: Rect, p: Point) -> bool {
rect.contains(p)
}
/// Prostokąt przycisku czyszczenia („×”).
pub fn clear_rect(&self, rect: Rect, theme: &Theme) -> Rect {
let m = theme.metrics.entry;
Rect::from_xywh(
rect.right() - m.pad_x - m.icon - self.trailing,
rect.center().y - m.icon / 2.0,
m.icon,
m.icon,
)
}
/// Czy punkt trafia w przycisk czyszczenia (tylko gdy pole czyszczalne i niepuste).
pub fn clear_at(&self, rect: Rect, theme: &Theme, p: Point) -> bool {
self.clearable && !self.text.is_empty() && self.clear_rect(rect, theme).contains(p)
}
/// Tekst do wyświetlenia (z uwzględnieniem maski hasła i podglądu).
fn display_text(&self) -> String {
match (self.mask, self.peek) {
(Some(ch), false) => ch.to_string().repeat(self.text.chars().count()),
_ => self.text.clone(),
}
}
/// Prefiks tekstu (w wersji wyświetlanej) do granicy `index` — z uwzględnieniem maski.
fn display_prefix_at(&self, index: usize) -> String {
let upto = &self.text[..index];
match (self.mask, self.peek) {
(Some(ch), false) => ch.to_string().repeat(upto.chars().count()),
_ => upto.to_string(),
}
}
/// Prefiks tekstu do kursora w wersji wyświetlanej (do wyliczenia pozycji kursora).
fn display_prefix(&self) -> String {
self.display_prefix_at(self.cursor)
}
/// Pozycja (x), od której zaczyna się tekst (za ikoną wiodącą, jeśli jest).
fn text_x(&self, rect: Rect, theme: &Theme) -> f64 {
let m = theme.metrics.entry;
match self.leading {
Some(_) => rect.x() + m.pad_x + m.icon + 8.0,
None => rect.x() + m.pad_x,
}
}
pub fn paint(&self, ctx: &mut PaintCtx, rect: Rect) {
let theme = *ctx.theme;
let m = theme.metrics.entry;
let colors = theme.colors;
let radius = theme.radius.medium;
ctx.scene.fill_rounded_rect(rect, radius, colors.shade);
let (border, width) = if self.focused {
(colors.accent, 2.0)
} else {
(colors.border, 1.0)
};
ctx.scene.stroke_rounded_rect(rect, radius, width, border);
let cy = rect.center().y;
if let Some(icon_kind) = self.leading {
match icon_kind {
EntryIcon::Search => icon::search(
ctx,
colors.dim_text,
Point::new(rect.x() + m.pad_x + m.icon / 2.0, cy),
m.icon,
),
}
}
let text_x = self.text_x(rect, &theme);
if self.text.is_empty() {
ctx.label_left(
text_x,
cy,
&self.placeholder,
theme.typography.body.size,
weight::NORMAL,
colors.dim_text,
);
} else {
let clear_space = if self.clearable { m.icon + 8.0 } else { 0.0 };
let text_right = rect.right() - m.pad_x - clear_space - self.trailing;
ctx.scene.push_clip(Rect::from_xywh(
text_x,
rect.y(),
(text_right - text_x).max(0.0),
rect.height(),
));
// Podświetlenie zaznaczenia (pod tekstem).
if let Some((a, b)) = self.selection() {
let size = theme.typography.body.size;
let x1 = text_x + ctx.measure(&self.display_prefix_at(a), size, weight::NORMAL);
let x2 = text_x + ctx.measure(&self.display_prefix_at(b), size, weight::NORMAL);
ctx.scene.fill_rect(
Rect::from_xywh(
x1,
rect.y() + 5.0,
(x2 - x1).max(0.0),
(rect.height() - 10.0).max(0.0),
),
colors.accent.scale_alpha(0.30),
);
}
let display = self.display_text();
ctx.label_left(
text_x,
cy,
&display,
theme.typography.body.size,
weight::NORMAL,
colors.text,
);
if self.focused {
let prefix = self.display_prefix();
let cx = text_x + ctx.measure(&prefix, theme.typography.body.size, weight::NORMAL);
ctx.scene.fill_rect(
Rect::from_xywh(cx, rect.y() + 6.0, 1.5, (rect.height() - 12.0).max(0.0)),
colors.text,
);
}
ctx.scene.pop_clip();
}
if self.clearable && !self.text.is_empty() {
let cb = self.clear_rect(rect, &theme);
if self.hover_clear {
ctx.scene
.fill_rounded_rect(cb, cb.width() / 2.0, colors.shade.scale_alpha(1.8));
}
icon::close(ctx, colors.dim_text, cb, 4.0);
}
}
}
/// Wspólny interfejs edycji tekstu dla pól ([`Entry`], [`SearchEntry`](crate::SearchEntry)).
///
/// Pozwala aplikacji obsłużyć zdarzenia klawiatury jedną funkcją, niezależnie od tego,
/// czy pole jest „gołym” `Entry`, czy wyspecjalizowanym wariantem.
pub trait TextInput {
fn insert_str(&mut self, s: &str);
fn insert_char(&mut self, c: char);
fn backspace(&mut self);
fn delete_forward(&mut self);
fn move_left(&mut self);
fn move_right(&mut self);
fn home(&mut self);
fn end(&mut self);
/// Ruch w lewo; z `shift` rozciąga zaznaczenie.
fn move_left_selecting(&mut self, shift: bool);
/// Ruch w prawo; z `shift` rozciąga zaznaczenie.
fn move_right_selecting(&mut self, shift: bool);
fn home_selecting(&mut self, shift: bool);
fn end_selecting(&mut self, shift: bool);
/// Zaznacza cały tekst.
fn select_all(&mut self);
/// Usuwa zaznaczenie (bez zmiany tekstu).
fn clear_selection(&mut self);
}
impl TextInput for Entry {
fn insert_str(&mut self, s: &str) {
Entry::insert_str(self, s);
}
fn insert_char(&mut self, c: char) {
Entry::insert_char(self, c);
}
fn backspace(&mut self) {
Entry::backspace(self);
}
fn delete_forward(&mut self) {
Entry::delete_forward(self);
}
fn move_left(&mut self) {
Entry::move_left(self);
}
fn move_right(&mut self) {
Entry::move_right(self);
}
fn home(&mut self) {
Entry::home(self);
}
fn end(&mut self) {
Entry::end(self);
}
fn move_left_selecting(&mut self, shift: bool) {
Entry::move_left_selecting(self, shift);
}
fn move_right_selecting(&mut self, shift: bool) {
Entry::move_right_selecting(self, shift);
}
fn home_selecting(&mut self, shift: bool) {
Entry::home_selecting(self, shift);
}
fn end_selecting(&mut self, shift: bool) {
Entry::end_selecting(self, shift);
}
fn select_all(&mut self) {
Entry::select_all(self);
}
fn clear_selection(&mut self) {
Entry::clear_selection(self);
}
}
fn prev_boundary(s: &str, i: usize) -> usize {
s[..i]
.char_indices()
.next_back()
.map(|(j, _)| j)
.unwrap_or(0)
}
fn next_boundary(s: &str, i: usize) -> usize {
s[i..].chars().next().map(|c| i + c.len_utf8()).unwrap_or(i)
}
/// Przybliża indeks do najbliższej granicy znaku (w dół) i do długości napisu.
fn clamp_boundary(s: &str, i: usize) -> usize {
let mut b = i.min(s.len());
while b > 0 && !s.is_char_boundary(b) {
b -= 1;
}
b
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn typing_and_cursor() {
let mut e = Entry::new("Nazwa");
e.insert_str("abc");
assert_eq!(e.text(), "abc");
assert_eq!(e.cursor(), 3);
e.move_left();
e.insert_char('X');
assert_eq!(e.text(), "abXc");
e.backspace();
assert_eq!(e.text(), "abc");
assert_eq!(e.cursor(), 2);
e.delete_forward();
assert_eq!(e.text(), "ab");
e.home();
e.delete_forward();
assert_eq!(e.text(), "b");
}
#[test]
fn multibyte_is_char_aware() {
let mut e = Entry::new("");
e.insert_str("ząb");
e.backspace();
assert_eq!(e.text(), "zą");
e.move_left();
e.backspace();
assert_eq!(e.text(), "ą");
}
#[test]
fn clear_resets_state() {
let mut e = Entry::new("");
e.insert_str("x");
e.clear();
assert!(e.is_empty());
assert_eq!(e.cursor(), 0);
}
#[test]
fn clear_hit_requires_clearable_and_text() {
let theme = Theme::light();
let rect = Rect::from_xywh(0.0, 0.0, 200.0, 34.0);
let mut plain = Entry::new("");
plain.insert_str("abc");
let c = plain.clear_rect(rect, &theme).center();
// Pole nieczyszczalne: brak trafienia nawet z tekstem.
assert!(!plain.clear_at(rect, &theme, c));
let mut e = Entry::new("").clearable(true);
assert!(!e.clear_at(rect, &theme, c));
e.insert_str("abc");
assert!(e.clear_at(rect, &theme, c));
}
#[test]
fn height_from_metrics() {
let theme = Theme::light();
assert_eq!(Entry::new("").height(&theme), theme.metrics.entry.height);
}
#[test]
fn selection_copy_cut_paste() {
let mut e = Entry::new("");
e.set_text("hello world");
e.set_selection(0, 5);
assert_eq!(e.selected_text(), Some("hello"));
assert_eq!(e.copy(), Some("hello".to_owned()));
// Wklejenie zastępuje zaznaczenie.
e.paste("HELLO");
assert_eq!(e.text(), "HELLO world");
assert!(!e.has_selection());
// Wycinanie.
e.set_selection(0, 6);
assert_eq!(e.cut(), Some("HELLO ".to_owned()));
assert_eq!(e.text(), "world");
assert_eq!(e.cursor(), 0);
}
#[test]
fn shift_extends_selection_and_typing_replaces() {
let mut e = Entry::new("");
e.set_text("abc");
e.home();
e.move_right_selecting(true);
e.move_right_selecting(true);
assert_eq!(e.selected_text(), Some("ab"));
e.insert_char('X');
assert_eq!(e.text(), "Xc");
}
#[test]
fn backspace_deletes_selection() {
let mut e = Entry::new("");
e.set_text("abcdef");
e.set_selection(1, 4);
e.backspace();
assert_eq!(e.text(), "aef");
assert_eq!(e.cursor(), 1);
}
#[test]
fn select_all_clamps_to_char_boundary() {
let mut e = Entry::new("");
e.set_text("jąbko");
e.select_all();
assert_eq!(e.selected_text(), Some("jąbko"));
e.set_selection(2, 2); // środek znaku → przycięte
assert!(!e.has_selection());
e.clear_selection();
assert_eq!(e.selection(), None);
}
}