bubbletea_widgets/textarea/
memoization.rs

1//! Memoization utilities for the textarea component.
2//!
3//! This module contains a simple, hash-keyed cache for memoizing soft-wrapped
4//! lines. The `MemoizedWrap` struct provides the wrapping entrypoint used by
5//! `textarea::Model` to compute visual sub-lines for a given logical line and
6//! width. Results are cached to avoid repeated recomputation while navigating.
7//!
8//! The generic `MemoCache` is a small LRU cache, provided for parity with the
9//! upstream design. The current `MemoizedWrap` uses an internal `HashMap` and
10//! fixed capacity to keep the implementation straightforward.
11
12use std::collections::{HashMap, VecDeque};
13use std::hash::Hash;
14use std::marker::PhantomData;
15use unicode_width::UnicodeWidthChar;
16
17/// A trait for objects that can provide a hash for memoization.
18/// This matches the Go Hasher interface.
19pub trait Hasher {
20    /// Returns a stable hash key representing the object's state for caching.
21    fn hash_key(&self) -> String;
22}
23
24/// Entry in the memoization cache
25#[derive(Debug, Clone)]
26struct CacheEntry<T> {
27    value: T,
28    access_order: usize,
29}
30
31/// Generic memoization cache with LRU eviction policy.
32/// This is a direct port of Go's MemoCache with generics.
33#[derive(Debug)]
34pub struct MemoCache<H, T>
35where
36    H: Hasher + Clone,
37    T: Clone,
38{
39    capacity: usize,
40    cache: HashMap<String, CacheEntry<T>>,
41    access_counter: usize,
42    eviction_queue: VecDeque<String>,
43    _phantom: PhantomData<H>,
44}
45
46impl<H, T> MemoCache<H, T>
47where
48    H: Hasher + Clone,
49    T: Clone,
50{
51    /// Create a new memoization cache with the given capacity
52    pub fn new(capacity: usize) -> Self {
53        Self {
54            capacity,
55            cache: HashMap::new(),
56            access_counter: 0,
57            eviction_queue: VecDeque::new(),
58            _phantom: PhantomData,
59        }
60    }
61
62    /// Get the capacity of the cache
63    pub fn capacity(&self) -> usize {
64        self.capacity
65    }
66
67    /// Get the current size of the cache
68    pub fn size(&self) -> usize {
69        self.cache.len()
70    }
71
72    /// Get a value from the cache
73    pub fn get(&mut self, hashable: &H) -> Option<T> {
74        let key = hashable.hash_key();
75
76        if let Some(entry) = self.cache.get_mut(&key) {
77            // Update access order for LRU
78            self.access_counter += 1;
79            entry.access_order = self.access_counter;
80
81            // Move to front in eviction queue
82            if let Some(pos) = self.eviction_queue.iter().position(|x| x == &key) {
83                self.eviction_queue.remove(pos);
84            }
85            self.eviction_queue.push_front(key);
86
87            Some(entry.value.clone())
88        } else {
89            None
90        }
91    }
92
93    /// Set a value in the cache
94    pub fn set(&mut self, hashable: &H, value: T) {
95        let key = hashable.hash_key();
96
97        // If key already exists, update it
98        if self.cache.contains_key(&key) {
99            self.access_counter += 1;
100            self.cache.insert(
101                key.clone(),
102                CacheEntry {
103                    value,
104                    access_order: self.access_counter,
105                },
106            );
107
108            // Move to front
109            if let Some(pos) = self.eviction_queue.iter().position(|x| x == &key) {
110                self.eviction_queue.remove(pos);
111            }
112            self.eviction_queue.push_front(key);
113            return;
114        }
115
116        // Check if we need to evict
117        if self.cache.len() >= self.capacity {
118            self.evict_lru();
119        }
120
121        // Add new entry
122        self.access_counter += 1;
123        self.cache.insert(
124            key.clone(),
125            CacheEntry {
126                value,
127                access_order: self.access_counter,
128            },
129        );
130        self.eviction_queue.push_front(key);
131    }
132
133    /// Evict the least recently used item
134    fn evict_lru(&mut self) {
135        if let Some(lru_key) = self.eviction_queue.pop_back() {
136            self.cache.remove(&lru_key);
137        }
138    }
139
140    /// Clear the cache
141    pub fn clear(&mut self) {
142        self.cache.clear();
143        self.eviction_queue.clear();
144        self.access_counter = 0;
145    }
146}
147
148/// Line input for text wrapping, implementing Hasher trait
149#[derive(Debug, Clone, PartialEq, Eq, Hash)]
150pub struct Line {
151    /// Characters comprising the line to wrap.
152    pub runes: Vec<char>,
153    /// Target wrap width in columns.
154    pub width: usize,
155}
156
157impl Hasher for Line {
158    fn hash_key(&self) -> String {
159        use std::collections::hash_map::DefaultHasher;
160        use std::hash::{Hash, Hasher as StdHasher};
161
162        let mut hasher = DefaultHasher::new();
163        self.hash(&mut hasher);
164        format!("{:x}", hasher.finish())
165    }
166}
167
168/// Memoized text wrapping functionality
169#[derive(Debug)]
170pub struct MemoizedWrap {
171    cache: HashMap<String, Vec<Vec<char>>>,
172}
173
174impl MemoizedWrap {
175    /// Create a new memoized wrapper with default capacity
176    pub fn new() -> Self {
177        Self::with_capacity(10000) // Match Go's maxLines constant
178    }
179
180    /// Create a new memoized wrapper with specified capacity
181    pub fn with_capacity(_capacity: usize) -> Self {
182        Self {
183            cache: HashMap::new(),
184        }
185    }
186
187    /// Get memoized wrapped text
188    pub fn wrap(&mut self, runes: &[char], width: usize) -> Vec<Vec<char>> {
189        let line = Line {
190            runes: runes.to_vec(),
191            width,
192        };
193
194        let key = line.hash_key();
195        if let Some(cached) = self.cache.get(&key) {
196            return cached.clone();
197        }
198
199        let wrapped = self.do_wrap(runes, width);
200        self.cache.insert(key, wrapped.clone());
201        wrapped
202    }
203
204    /// Wrap text lines (port of Go wrap function)
205    fn do_wrap(&self, runes: &[char], width: usize) -> Vec<Vec<char>> {
206        let mut lines = vec![Vec::new()];
207        let mut word = Vec::new();
208        let mut row = 0;
209        let mut spaces = 0;
210
211        // Word wrap the runes
212        for &r in runes {
213            if r.is_whitespace() {
214                spaces += 1;
215            } else {
216                word.push(r);
217            }
218
219            if spaces > 0 {
220                let current_line_width = self.line_width(&lines[row]);
221                let word_width = self.line_width(&word);
222
223                if current_line_width + word_width + spaces > width {
224                    row += 1;
225                    lines.push(Vec::new());
226                    lines[row].extend_from_slice(&word);
227                    lines[row].extend(std::iter::repeat_n(' ', spaces));
228                    spaces = 0;
229                    word.clear();
230                } else {
231                    lines[row].extend_from_slice(&word);
232                    lines[row].extend(std::iter::repeat_n(' ', spaces));
233                    spaces = 0;
234                    word.clear();
235                }
236            } else {
237                // If the last character is double-width, check if we can add it
238                let last_char_width = word
239                    .last()
240                    .map(|&ch| UnicodeWidthChar::width(ch).unwrap_or(0))
241                    .unwrap_or(0);
242                let word_width = self.line_width(&word);
243
244                if word_width + last_char_width > width {
245                    // Move to next line if current line has content
246                    if !lines[row].is_empty() {
247                        row += 1;
248                        lines.push(Vec::new());
249                    }
250                    lines[row].extend_from_slice(&word);
251                    word.clear();
252                }
253            }
254        }
255
256        // Handle remaining word and spaces
257        let current_line_width = self.line_width(&lines[row]);
258        let word_width = self.line_width(&word);
259
260        if current_line_width + word_width + spaces >= width {
261            lines.push(Vec::new());
262            lines[row + 1].extend_from_slice(&word);
263            // Add trailing space for soft-wrapped lines
264            spaces += 1;
265            lines[row + 1].extend(std::iter::repeat_n(' ', spaces));
266        } else {
267            lines[row].extend_from_slice(&word);
268            spaces += 1;
269            lines[row].extend(std::iter::repeat_n(' ', spaces));
270        }
271
272        lines
273    }
274
275    /// Calculate the display width of a line
276    fn line_width(&self, line: &[char]) -> usize {
277        line.iter()
278            .map(|&ch| UnicodeWidthChar::width(ch).unwrap_or(0))
279            .sum()
280    }
281
282    /// Clear the memoization cache
283    pub fn clear_cache(&mut self) {
284        self.cache.clear();
285    }
286
287    /// Get cache capacity
288    pub fn capacity(&self) -> usize {
289        10000 // Fixed capacity
290    }
291
292    /// Get current cache size
293    pub fn size(&self) -> usize {
294        self.cache.len()
295    }
296}
297
298impl Default for MemoizedWrap {
299    fn default() -> Self {
300        Self::new()
301    }
302}
303
304impl Clone for MemoizedWrap {
305    fn clone(&self) -> Self {
306        // Create a new cache since MemoCache doesn't implement Clone
307        Self::with_capacity(self.capacity())
308    }
309}