🔒 Repository is read-only – file editing is disabled.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413
//! Pola specjalne GTK4: `PasswordEntry` i `EditableLabel`.
//!
//! Oba są cienkimi nakładkami na [`Entry`]: cała logika edycji (kursor, wstawianie,
//! usuwanie) żyje w polu bazowym, a tutaj dodajemy tylko cechy charakterystyczne.
use libluna_core::style::weight;
use libluna_core::{Point, Rect, Theme};
use crate::ctx::PaintCtx;
use crate::entry::{Entry, TextInput};
use crate::icon;
/// Pole hasła: [`Entry`] z maskowaniem (`•`) i przyciskiem podglądu („oko”).
#[derive(Debug, Clone, Default)]
pub struct PasswordEntry {
entry: Entry,
peek_hover: bool,
}
impl PasswordEntry {
pub fn new(placeholder: impl Into<String>) -> Self {
Self {
entry: Entry::new(placeholder).masked('•'),
peek_hover: false,
}
}
pub fn text(&self) -> &str {
self.entry.text()
}
pub fn is_empty(&self) -> bool {
self.entry.is_empty()
}
pub fn cursor(&self) -> usize {
self.entry.cursor()
}
pub fn is_focused(&self) -> bool {
self.entry.is_focused()
}
/// Czy hasło jest chwilowo widoczne (podgląd).
pub fn is_peek(&self) -> bool {
self.entry.is_peek()
}
pub fn set_peek(&mut self, peek: bool) {
self.entry.set_peek(peek);
}
/// Przełącza podgląd; zwraca nowy stan.
pub fn toggle_peek(&mut self) -> bool {
let v = !self.entry.is_peek();
self.entry.set_peek(v);
v
}
pub fn set_text(&mut self, text: impl Into<String>) {
self.entry.set_text(text);
}
pub fn set_focused(&mut self, focused: bool) {
self.entry.set_focused(focused);
}
pub fn set_placeholder(&mut self, placeholder: impl Into<String>) {
self.entry.set_placeholder(placeholder);
}
pub fn clear(&mut self) {
self.entry.clear();
}
pub fn insert_str(&mut self, s: &str) {
self.entry.insert_str(s);
}
pub fn insert_char(&mut self, c: char) {
self.entry.insert_char(c);
}
pub fn backspace(&mut self) {
self.entry.backspace();
}
pub fn delete_forward(&mut self) {
self.entry.delete_forward();
}
pub fn move_left(&mut self) {
self.entry.move_left();
}
pub fn move_right(&mut self) {
self.entry.move_right();
}
pub fn home(&mut self) {
self.entry.home();
}
pub fn end(&mut self) {
self.entry.end();
}
pub fn selection(&self) -> Option<(usize, usize)> {
self.entry.selection()
}
pub fn has_selection(&self) -> bool {
self.entry.has_selection()
}
pub fn selected_text(&self) -> Option<&str> {
self.entry.selected_text()
}
pub fn set_selection(&mut self, start: usize, end: usize) {
self.entry.set_selection(start, end);
}
pub fn select_all(&mut self) {
self.entry.select_all();
}
pub fn clear_selection(&mut self) {
self.entry.clear_selection();
}
pub fn copy(&self) -> Option<String> {
self.entry.copy()
}
pub fn cut(&mut self) -> Option<String> {
self.entry.cut()
}
pub fn paste(&mut self, s: &str) {
self.entry.paste(s);
}
pub fn height(&self, theme: &Theme) -> f64 {
self.entry.height(theme)
}
pub fn contains(&self, rect: Rect, p: Point) -> bool {
self.entry.contains(rect, p)
}
/// Prostokąt przycisku podglądu („oko”) po prawej stronie pola.
pub fn peek_rect(&self, rect: Rect, theme: &Theme) -> Rect {
let m = theme.metrics.entry;
Rect::from_xywh(
rect.right() - m.pad_x - m.icon,
rect.center().y - m.icon / 2.0,
m.icon,
m.icon,
)
}
pub fn peek_at(&self, rect: Rect, theme: &Theme, p: Point) -> bool {
self.peek_rect(rect, theme).contains(p)
}
pub fn set_peek_hover(&mut self, hovered: bool) {
self.peek_hover = hovered;
}
/// Obsługuje klik: trafienie w „oko” przełącza podgląd. Zwraca `true`, gdy zmieniono stan.
pub fn click_at(&mut self, rect: Rect, theme: &Theme, p: Point) -> bool {
if self.peek_at(rect, theme, p) {
self.toggle_peek();
true
} else {
false
}
}
pub fn paint(&self, ctx: &mut PaintCtx, rect: Rect) {
let theme = *ctx.theme;
let m = theme.metrics.entry;
// Zarezerwuj miejsce na „oko”, by tekst nie wchodził pod ikonę.
let mut entry = self.entry.clone();
entry.set_trailing_space(m.icon + 8.0);
entry.paint(ctx, rect);
let eye = self.peek_rect(rect, &theme);
if self.peek_hover {
ctx.scene.fill_rounded_rect(
eye,
eye.width() / 2.0,
theme.colors.shade.scale_alpha(1.8),
);
}
let color = if self.entry.is_peek() {
theme.colors.accent
} else {
theme.colors.dim_text
};
if self.entry.is_peek() {
icon::eye(ctx, color, eye.center(), m.icon * 0.42);
} else {
icon::eye_off(ctx, color, eye.center(), m.icon * 0.42);
}
}
}
impl TextInput for PasswordEntry {
fn insert_str(&mut self, s: &str) {
PasswordEntry::insert_str(self, s);
}
fn insert_char(&mut self, c: char) {
PasswordEntry::insert_char(self, c);
}
fn backspace(&mut self) {
PasswordEntry::backspace(self);
}
fn delete_forward(&mut self) {
PasswordEntry::delete_forward(self);
}
fn move_left(&mut self) {
PasswordEntry::move_left(self);
}
fn move_right(&mut self) {
PasswordEntry::move_right(self);
}
fn home(&mut self) {
PasswordEntry::home(self);
}
fn end(&mut self) {
PasswordEntry::end(self);
}
fn move_left_selecting(&mut self, shift: bool) {
self.entry.move_left_selecting(shift);
}
fn move_right_selecting(&mut self, shift: bool) {
self.entry.move_right_selecting(shift);
}
fn home_selecting(&mut self, shift: bool) {
self.entry.home_selecting(shift);
}
fn end_selecting(&mut self, shift: bool) {
self.entry.end_selecting(shift);
}
fn select_all(&mut self) {
self.entry.select_all();
}
fn clear_selection(&mut self) {
self.entry.clear_selection();
}
}
/// Etykieta edytowalna: pokazuje tekst, a po wejściu w edycję zamienia się w [`Entry`].
#[derive(Debug, Clone, Default)]
pub struct EditableLabel {
text: String,
editing: bool,
entry: Entry,
}
impl EditableLabel {
pub fn new(text: impl Into<String>) -> Self {
let text = text.into();
let mut entry = Entry::new("");
entry.set_text(text.clone());
Self {
text,
editing: false,
entry,
}
}
pub fn text(&self) -> &str {
&self.text
}
pub fn set_text(&mut self, text: impl Into<String>) {
self.text = text.into();
self.entry.set_text(self.text.clone());
}
pub fn is_editing(&self) -> bool {
self.editing
}
/// Wchodzi w tryb edycji (kursor na końcu tekstu).
pub fn start_editing(&mut self) {
self.editing = true;
self.entry.set_text(self.text.clone());
self.entry.set_focused(true);
}
/// Zatwierdza edycję: tekst pola staje się tekstem etykiety.
pub fn commit(&mut self) {
if self.editing {
self.text = self.entry.text().to_string();
self.editing = false;
self.entry.set_focused(false);
}
}
/// Anuluje edycję (przywraca poprzedni tekst).
pub fn cancel(&mut self) {
if self.editing {
self.editing = false;
self.entry.set_text(self.text.clone());
self.entry.set_focused(false);
}
}
pub fn height(&self, theme: &Theme) -> f64 {
theme.metrics.entry.height
}
/// Deleguje edycję do pola bazowego (gdy trwa edycja).
pub fn entry_mut(&mut self) -> &mut Entry {
&mut self.entry
}
pub fn insert_str(&mut self, s: &str) {
if self.editing {
self.entry.insert_str(s);
}
}
pub fn backspace(&mut self) {
if self.editing {
self.entry.backspace();
}
}
pub fn paint(&self, ctx: &mut PaintCtx, rect: Rect) {
let theme = *ctx.theme;
if self.editing {
let mut entry = self.entry.clone();
entry.set_focused(true);
entry.paint(ctx, rect);
} else {
ctx.label_left(
rect.x(),
rect.center().y,
&self.text,
theme.typography.body.size,
weight::NORMAL,
theme.colors.text,
);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn rect() -> Rect {
Rect::from_xywh(0.0, 0.0, 200.0, 34.0)
}
#[test]
fn password_masks_but_keeps_text() {
let theme = Theme::light();
let mut p = PasswordEntry::new("Hasło");
p.insert_str("abc123");
assert_eq!(p.text(), "abc123");
assert!(p.entry.is_masked());
assert!(!p.is_peek());
// Klik w „oko” włącza podgląd.
let eye = p.peek_rect(rect(), &theme).center();
assert!(p.click_at(rect(), &theme, eye));
assert!(p.is_peek());
assert!(p.click_at(rect(), &theme, eye));
assert!(!p.is_peek());
// Klik poza „okiem” nic nie zmienia.
assert!(!p.click_at(rect(), &theme, Point::new(5.0, 5.0)));
}
#[test]
fn password_editing_delegates() {
let mut p = PasswordEntry::new("");
p.insert_str("ząb");
assert_eq!(p.text(), "ząb");
p.backspace();
assert_eq!(p.text(), "zą");
}
#[test]
fn editable_label_commit_and_cancel() {
let mut l = EditableLabel::new("Stara");
assert_eq!(l.text(), "Stara");
assert!(!l.is_editing());
l.start_editing();
assert!(l.is_editing());
l.entry_mut().set_text("Nowa");
l.commit();
assert!(!l.is_editing());
assert_eq!(l.text(), "Nowa");
l.start_editing();
l.entry_mut().set_text("Porzucona");
l.cancel();
assert_eq!(l.text(), "Nowa");
}
#[test]
fn editable_label_ignores_typing_when_not_editing() {
let mut l = EditableLabel::new("X");
l.insert_str("Y");
assert_eq!(l.text(), "X");
}
}