🔒 Repository is read-only – file editing is disabled.

luna/crates/libluna-widgets/src/text_view.rs main

509 lines Raw ← Back
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509
//! `TextView` — wielolinijkowe pole tekstowe (GTK4).
//!
//! Widget immediate: trzyma tekst (z `\n`) i indeks kursora na granicy znaku. Aplikacja
//! dostarcza zdarzenia klawiatury wywołaniami metod (`insert_str`, `backspace`, `move_up`…)
//! i sama decyduje o fokusie. Rysowanie nie zawija wierszy (brak mięk korekty) — to warstwa
//! aplikacji, gdy będzie potrzebna.
//!
//! [`Entry`](crate::Entry) to jednowierszowy odpowiednik.

use libluna_core::style::weight;
use libluna_core::{Rect, Theme};

use crate::ctx::PaintCtx;

/// Wielolinijkowe pole tekstowe.
#[derive(Debug, Clone, Default)]
pub struct TextView {
    text: String,
    /// Indeks bajtowy kursora (zawsze na granicy znaku).
    cursor: usize,
    placeholder: String,
    focused: bool,
    /// Kotwica zaznaczenia (bajt); `None` = brak zaznaczenia.
    anchor: Option<usize>,
}

impl TextView {
    pub fn new(placeholder: impl Into<String>) -> Self {
        Self {
            placeholder: placeholder.into(),
            ..Self::default()
        }
    }

    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 line_count(&self) -> usize {
        self.line_starts().len()
    }

    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_placeholder(&mut self, placeholder: impl Into<String>) {
        self.placeholder = placeholder.into();
    }

    pub fn set_focused(&mut self, focused: bool) {
        self.focused = focused;
    }

    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()
    }

    pub fn selected_text(&self) -> Option<&str> {
        self.selection().map(|(a, b)| &self.text[a..b])
    }

    /// Ustawia zaznaczenie `start..end` (przycinane do granic znaku).
    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);
    }

    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.
    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;
        }
    }

    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));
    }

    /// Wstawia znak nowej linii w miejscu kursora.
    pub fn insert_newline(&mut self) {
        self.insert_char('\n');
    }

    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;
        }
    }

    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);
    }

    pub fn move_left_selecting(&mut self, shift: bool) {
        self.begin_or_clear(shift);
        self.cursor = prev_boundary(&self.text, self.cursor);
    }

    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 = self.line_start(self.cursor_line());
    }

    pub fn end_selecting(&mut self, shift: bool) {
        self.begin_or_clear(shift);
        self.cursor = self.line_end(self.cursor_line());
    }

    /// Przechodzi wiersz w górę, zachowując kolumnę (o ile się mieści).
    pub fn move_up(&mut self) {
        self.move_up_selecting(false);
    }

    /// Przechodzi wiersz w dół, zachowując kolumnę (o ile się mieści).
    pub fn move_down(&mut self) {
        self.move_down_selecting(false);
    }

    pub fn move_up_selecting(&mut self, shift: bool) {
        self.begin_or_clear(shift);
        let line = self.cursor_line();
        if line == 0 {
            self.cursor = 0;
            return;
        }
        let col = self.cursor_col(line);
        let start = self.line_start(line - 1);
        let end = self.line_end(line - 1);
        self.cursor = start + byte_at_char(&self.text[start..end], col);
    }

    pub fn move_down_selecting(&mut self, shift: bool) {
        self.begin_or_clear(shift);
        let line = self.cursor_line();
        if line + 1 >= self.line_count() {
            self.cursor = self.text.len();
            return;
        }
        let col = self.cursor_col(line);
        let start = self.line_start(line + 1);
        let end = self.line_end(line + 1);
        self.cursor = start + byte_at_char(&self.text[start..end], col);
    }

    /// Wysokość treści (wszystkie wiersze).
    pub fn content_height(&self, theme: &Theme) -> f64 {
        self.line_count() as f64 * theme.typography.body.line_height
    }

    pub fn height(&self, theme: &Theme) -> f64 {
        theme.metrics.entry.height.max(self.content_height(theme))
    }

    /// Iteruje po wierszach tekstu.
    pub fn lines(&self) -> impl Iterator<Item = &str> {
        self.text.split('\n')
    }

    fn line_starts(&self) -> Vec<usize> {
        let mut starts = vec![0];
        for (i, b) in self.text.bytes().enumerate() {
            if b == b'\n' {
                starts.push(i + 1);
            }
        }
        starts
    }

    fn cursor_line(&self) -> usize {
        let starts = self.line_starts();
        starts
            .partition_point(|&s| s <= self.cursor)
            .saturating_sub(1)
    }

    fn line_start(&self, line: usize) -> usize {
        *self.line_starts().get(line).unwrap_or(&self.text.len())
    }

    fn line_end(&self, line: usize) -> usize {
        let starts = self.line_starts();
        match starts.get(line + 1) {
            Some(&next) => next - 1, // bez znaku '\n'
            None => self.text.len(),
        }
    }

    /// Kolumna kursora w danym wierszu (licząc znaki).
    fn cursor_col(&self, line: usize) -> usize {
        let start = self.line_start(line);
        self.text[start..self.cursor].chars().count()
    }

    pub fn paint(&self, ctx: &mut PaintCtx, rect: Rect) {
        let theme = *ctx.theme;
        let radius = theme.radius.medium;
        let colors = theme.colors;
        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 pad = theme.spacing.sm;
        let inner = Rect::from_xywh(
            rect.x() + pad,
            rect.y() + pad,
            (rect.width() - 2.0 * pad).max(0.0),
            (rect.height() - 2.0 * pad).max(0.0),
        );
        let body = theme.typography.body.size;
        let lh = theme.typography.body.line_height;

        ctx.scene.push_clip(inner);
        if self.text.is_empty() {
            ctx.label_left(
                inner.x(),
                inner.y() + lh / 2.0,
                &self.placeholder,
                body,
                weight::NORMAL,
                colors.dim_text,
            );
        } else {
            // Podświetlenie zaznaczenia (per wiersz).
            if let Some((a, b)) = self.selection() {
                let starts = self.line_starts();
                for (i, &start) in starts.iter().enumerate() {
                    let end = if i + 1 < starts.len() {
                        starts[i + 1] - 1
                    } else {
                        self.text.len()
                    };
                    let lo = a.max(start);
                    let hi = b.min(end);
                    if lo < hi {
                        let y = inner.y() + i as f64 * lh;
                        let x1 =
                            inner.x() + ctx.measure(&self.text[start..lo], body, weight::NORMAL);
                        let x2 =
                            inner.x() + ctx.measure(&self.text[start..hi], body, weight::NORMAL);
                        ctx.scene.fill_rect(
                            Rect::from_xywh(x1, y + 2.0, (x2 - x1).max(0.0), (lh - 4.0).max(0.0)),
                            colors.accent.scale_alpha(0.30),
                        );
                    }
                }
            }
            for (i, line) in self.lines().enumerate() {
                let y = inner.y() + i as f64 * lh + lh / 2.0;
                if y - lh / 2.0 > inner.bottom() {
                    break;
                }
                ctx.label_left(inner.x(), y, line, body, weight::NORMAL, colors.text);
            }
            if self.focused {
                let line = self.cursor_line();
                let start = self.line_start(line);
                let col_x = ctx.measure(&self.text[start..self.cursor], body, weight::NORMAL);
                let cx = inner.x() + col_x;
                let cy = inner.y() + line as f64 * lh;
                ctx.scene.fill_rect(
                    Rect::from_xywh(cx, cy + 2.0, 1.5, (lh - 4.0).max(0.0)),
                    colors.text,
                );
            }
        }
        ctx.scene.pop_clip();
    }
}

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

/// Indeks bajtowy `col`-tego znaku w napisie (lub długość, gdy za krótki).
fn byte_at_char(s: &str, col: usize) -> usize {
    s.char_indices().nth(col).map(|(i, _)| i).unwrap_or(s.len())
}

/// 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 newline_and_line_count() {
        let mut t = TextView::new("");
        t.insert_str("ab\ncd");
        assert_eq!(t.line_count(), 2);
        assert_eq!(t.text(), "ab\ncd");
        assert_eq!(t.cursor(), 5);
    }

    #[test]
    fn backspace_removes_newline() {
        let mut t = TextView::new("");
        t.insert_str("a\nb");
        t.move_left(); // przed 'b'
        t.backspace(); // usuwa '\n'
        assert_eq!(t.text(), "ab");
    }

    #[test]
    fn vertical_navigation_keeps_column() {
        let mut t = TextView::new("");
        t.set_text("hello\nhi\nworld");
        t.home(); // początek ostatniego wiersza ("world")
        t.move_up(); // wiersz "hi", kolumna 0
        t.move_right();
        t.move_right(); // kolumna 2 w "hi" (koniec wiersza)
        assert_eq!(t.cursor(), "hello\nhi".len());
        t.move_down(); // wiersz "world", kolumna 2
        assert_eq!(t.cursor(), "hello\nhi\nwo".len());
    }

    #[test]
    fn home_and_end_stay_in_line() {
        let mut t = TextView::new("");
        t.set_text("one\ntwo\nthree");
        t.end();
        assert_eq!(t.cursor(), t.text().len());
        t.home();
        assert_eq!(t.cursor(), "one\ntwo\n".len());
        t.move_up();
        assert_eq!(t.cursor(), "one\n".len());
        t.home();
        assert_eq!(t.cursor(), "one\n".len()); // już na początku wiersza
        t.move_up();
        assert_eq!(t.cursor(), 0);
        t.move_up(); // brak wiersza wyżej
        assert_eq!(t.cursor(), 0);
        t.end(); // koniec wiersza "one"
        assert_eq!(t.cursor(), "one".len());
    }

    #[test]
    fn placeholder_and_content_height() {
        let theme = Theme::light();
        let mut t = TextView::new("Notatka…");
        assert_eq!(t.line_count(), 1);
        assert!(t.content_height(&theme) > 0.0);
        t.insert_str("a\nb\nc");
        assert_eq!(
            t.content_height(&theme),
            3.0 * theme.typography.body.line_height
        );
    }

    #[test]
    fn multiline_selection_and_editing() {
        let mut t = TextView::new("");
        t.set_text("one\ntwo\nthree");
        // Zaznacz od końca "one" do początku "three".
        t.set_selection(3, 8);
        assert_eq!(t.selected_text(), Some("\ntwo\n"));
        assert_eq!(t.copy(), Some("\ntwo\n".to_owned()));
        t.paste(" | ");
        assert_eq!(t.text(), "one | three");
        assert!(!t.has_selection());
    }

    #[test]
    fn shift_selects_by_lines() {
        let mut t = TextView::new("");
        t.set_text("ab\ncd");
        t.home();
        t.move_up_selecting(true); // brak wiersza wyżej → kursor na 0, kotwica wcześniej
        t.move_right_selecting(true);
        assert!(t.has_selection());
    }

    #[test]
    fn backspace_removes_selection() {
        let mut t = TextView::new("");
        t.set_text("abcdef");
        t.set_selection(1, 4);
        t.backspace();
        assert_eq!(t.text(), "aef");
        assert_eq!(t.cursor(), 1);
    }
}