use iced::Task;
use iced::widget::text_editor;
use std::future::Future;
use std::pin::Pin;
use tokio::sync::broadcast;
pub(crate) const MAX_INPUT_CHARS: usize = 100_000;
#[derive(Debug, Clone)]
pub(crate) struct PaginationState {
pub(crate) page: usize,
pub(crate) page_size: usize,
pub(crate) total: usize,
}
impl PaginationState {
pub(crate) const fn new(page_size: usize) -> Self {
Self {
page: 0,
page_size,
total: 0,
}
}
pub(crate) const fn total_pages(&self) -> usize {
if self.total == 0 {
0
} else {
self.total.div_ceil(self.page_size)
}
}
pub(crate) fn prev_page(&mut self) -> bool {
if self.page > 0 {
self.page -= 1;
true
} else {
false
}
}
pub(crate) fn next_page(&mut self) -> bool {
if self.page + 1 < self.total_pages() {
self.page += 1;
true
} else {
false
}
}
pub(crate) fn reset(&mut self) {
self.page = 0;
}
pub(crate) fn clamp_page(&mut self, total: usize) -> bool {
let total_pages = total.div_ceil(self.page_size);
if total_pages == 0 {
self.page = 0;
false
} else if self.page >= total_pages {
self.page = total_pages - 1;
true
} else {
false
}
}
pub(crate) fn offset(&self) -> usize {
self.page * self.page_size
}
}
#[derive(Debug, Clone)]
pub(crate) struct AsyncLoadState {
loading: bool,
has_loaded: bool,
error: Option<String>,
}
impl AsyncLoadState {
pub(crate) const fn new() -> Self {
Self {
loading: false,
has_loaded: false,
error: None,
}
}
pub(crate) fn loading(&self) -> bool {
self.loading
}
pub(crate) fn has_loaded(&self) -> bool {
self.has_loaded
}
pub(crate) fn error(&self) -> Option<&str> {
self.error.as_deref()
}
pub(crate) fn start_loading(&mut self) {
self.loading = true;
self.error = None;
}
pub(crate) fn finish_loading(&mut self) {
self.loading = false;
self.has_loaded = true;
}
pub(crate) fn fail(&mut self, error: String) {
self.error = Some(error);
self.loading = false;
}
pub(crate) fn clear_error(&mut self) {
self.error = None;
}
pub(crate) fn set_has_loaded(&mut self) {
self.has_loaded = true;
}
}
#[derive(Debug, Clone)]
pub(crate) struct DebounceState {
generation: u64,
pending: bool,
}
impl DebounceState {
pub(crate) const fn new() -> Self {
Self {
generation: 0,
pending: false,
}
}
pub(crate) fn trigger(&mut self, ms: u64) -> Task<u64> {
self.generation = self.generation.wrapping_add(1);
self.pending = true;
let current = self.generation;
Task::perform(
super::widgets::debounce_sleep(ms, current),
std::convert::identity,
)
}
#[must_use]
pub(crate) fn should_process(&mut self, generation: u64) -> bool {
if generation == self.generation && self.pending {
self.pending = false;
true
} else {
false
}
}
}
pub(crate) trait UndoableText {
fn text(&self) -> String;
fn cursor(&self) -> text_editor::Cursor;
}
impl UndoableText for text_editor::Content {
fn text(&self) -> String {
text_editor::Content::text(self)
}
fn cursor(&self) -> text_editor::Cursor {
text_editor::Content::cursor(self)
}
}
#[derive(Debug, Clone)]
pub(crate) struct UndoStack {
undo: Vec<UndoSnapshot>,
redo: Vec<UndoSnapshot>,
}
#[derive(Debug, Clone)]
pub(crate) struct UndoSnapshot {
pub(crate) text: String,
pub(crate) cursor: text_editor::Cursor,
}
impl UndoStack {
const MAX_UNDO_DEPTH: usize = 100;
const LARGE_FILE_UNDO_THRESHOLD: usize = 100_000;
pub(crate) const fn new() -> Self {
Self {
undo: Vec::new(),
redo: Vec::new(),
}
}
pub(crate) fn snap_before_edit(&mut self, content: &impl UndoableText) {
let text = content.text();
let max_depth = if text.len() > Self::LARGE_FILE_UNDO_THRESHOLD {
Self::MAX_UNDO_DEPTH / 2
} else {
Self::MAX_UNDO_DEPTH
};
self.redo.clear();
self.undo.push(UndoSnapshot {
text,
cursor: content.cursor(),
});
if self.undo.len() > max_depth {
self.undo.remove(0);
}
}
fn push_and_pop(
dst: &mut Vec<UndoSnapshot>,
src: &mut Vec<UndoSnapshot>,
content: &impl UndoableText,
) -> Option<UndoSnapshot> {
dst.push(UndoSnapshot {
text: content.text(),
cursor: content.cursor(),
});
src.pop()
}
pub(crate) fn undo(&mut self, content: &impl UndoableText) -> Option<UndoSnapshot> {
Self::push_and_pop(&mut self.redo, &mut self.undo, content)
}
pub(crate) fn redo(&mut self, content: &impl UndoableText) -> Option<UndoSnapshot> {
Self::push_and_pop(&mut self.undo, &mut self.redo, content)
}
pub(crate) fn clear(&mut self) {
self.undo.clear();
self.redo.clear();
}
}
pub(crate) fn broadcast_stream_producer<Msg, T, E>(
capacity: usize,
source: &'static std::sync::OnceLock<tokio::sync::broadcast::Sender<T>>,
mut emit: E,
) -> impl futures_util::Stream<Item = Msg>
where
Msg: Send + 'static,
T: Clone + Send + 'static,
E: FnMut(
&mut iced::futures::channel::mpsc::Sender<Msg>,
Option<T>,
) -> Pin<Box<dyn Future<Output = ()> + Send + '_>>
+ Send
+ 'static,
{
iced::stream::channel(
capacity,
move |mut output: iced::futures::channel::mpsc::Sender<Msg>| async move {
let Some(mut rx) = source.get().and_then(|tx| {
if tx.receiver_count() > 100 {
None
} else {
Some(tx.subscribe())
}
}) else {
return;
};
loop {
match rx.recv().await {
Ok(event) => emit(&mut output, Some(event)).await,
Err(broadcast::error::RecvError::Lagged(_n)) => {
emit(&mut output, None).await;
}
Err(broadcast::error::RecvError::Closed) => break,
}
}
},
)
}
pub(crate) fn composer_keyboard_event<M>(
event: iced::keyboard::Event,
mods_changed: fn(iced::keyboard::Modifiers) -> M,
undo: fn() -> M,
redo: fn() -> M,
) -> Option<M> {
match event {
iced::keyboard::Event::ModifiersChanged(modifiers) => Some(mods_changed(modifiers)),
iced::keyboard::Event::KeyPressed {
key,
modifiers,
physical_key,
..
} => {
let km = super::detect_keyboard_mods(modifiers);
if km.is_shortcut_platform_mod() && key.to_latin(physical_key) == Some('z') {
if modifiers.shift() {
return Some(redo());
}
return Some(undo());
}
None
}
iced::keyboard::Event::KeyReleased { .. } => None,
}
}
pub(crate) fn apply_editor_action(
content: &mut text_editor::Content,
undo_stack: &mut UndoStack,
action: text_editor::Action,
shift: bool,
) {
let action = match action {
text_editor::Action::Click(pos) if shift => text_editor::Action::Drag(pos),
other => other,
};
if action.is_edit() {
undo_stack.snap_before_edit(content);
}
content.perform(action);
}
pub(crate) fn restore_undo_snapshot(
content: &mut text_editor::Content,
snapshot: Option<UndoSnapshot>,
) {
if let Some(snapshot) = snapshot {
*content = text_editor::Content::with_text(&snapshot.text);
content.move_to(snapshot.cursor);
}
}
pub(crate) fn send_guard<M: 'static>(
text: &str,
sending: bool,
in_flight_first: bool,
over_limit: impl Fn(usize) -> Task<M>,
) -> Result<&str, Task<M>> {
let trimmed = text.trim();
if trimmed.is_empty() {
return Err(Task::none());
}
let over_limit_task = || {
let count = trimmed.chars().count();
if count > MAX_INPUT_CHARS {
Some(over_limit(count))
} else {
None
}
};
if in_flight_first {
if sending {
return Err(Task::none());
}
if let Some(task) = over_limit_task() {
return Err(task);
}
} else {
if let Some(task) = over_limit_task() {
return Err(task);
}
if sending {
return Err(Task::none());
}
}
Ok(trimmed)
}
#[cfg(test)]
mod tests {
use super::*;
use std::cell::Cell;
fn stack_with_snapshot(text: &str) -> UndoStack {
let mut stack = UndoStack::new();
let content = text_editor::Content::with_text(text);
stack.snap_before_edit(&content);
stack
}
#[test]
fn undo_restores_snapshot() {
let mut stack = stack_with_snapshot("original");
let modified = text_editor::Content::with_text("modified");
let snapshot = stack.undo(&modified).unwrap();
assert_eq!(snapshot.text, "original");
}
#[test]
fn redo_restores_undone_state() {
let mut stack = stack_with_snapshot("original");
let modified = text_editor::Content::with_text("modified");
let _ = stack.undo(&modified);
let snapshot = stack.redo(&modified).unwrap();
assert_eq!(snapshot.text, "modified");
}
#[test]
fn new_edit_clears_redo() {
let mut stack = stack_with_snapshot("v1");
let v2 = text_editor::Content::with_text("v2");
let _ = stack.undo(&v2);
let v3 = text_editor::Content::with_text("v3");
stack.snap_before_edit(&v3);
assert!(stack.redo(&v3).is_none());
}
#[test]
fn snapshot_preserves_cursor() {
let mut content = text_editor::Content::with_text("line1\nline2\nline3");
content.move_to(text_editor::Cursor {
position: text_editor::Position { line: 1, column: 2 },
selection: None,
});
let mut stack = UndoStack::new();
stack.snap_before_edit(&content);
let modified = text_editor::Content::with_text("changed");
let snapshot = stack.undo(&modified).unwrap();
assert_eq!(snapshot.cursor.position.line, 1);
assert_eq!(snapshot.cursor.position.column, 2);
}
fn run_guard(
text: &str,
sending: bool,
in_flight_first: bool,
) -> (bool, Result<&str, Task<()>>) {
let fired = Cell::new(false);
let result = send_guard(text, sending, in_flight_first, |_| {
fired.set(true);
Task::none()
});
(fired.get(), result)
}
#[test]
fn send_guard_rejects_empty_and_trims() {
let (fired, result) = run_guard(" \t ", false, true);
assert!(result.is_err() && !fired, "empty input is a silent noop");
let (_, result) = run_guard(" hello ", false, true);
assert_eq!(result.unwrap(), "hello");
}
#[test]
fn send_guard_limit_boundary() {
let at_limit = "a".repeat(MAX_INPUT_CHARS);
let (fired, result) = run_guard(&at_limit, false, true);
assert!(result.is_ok() && !fired, "at-limit text is accepted");
let over_limit = "a".repeat(MAX_INPUT_CHARS + 1);
let (fired, result) = run_guard(&over_limit, false, true);
assert!(result.is_err() && fired, "over-limit text is rejected");
}
#[test]
fn send_guard_combined_in_flight_and_over_limit() {
let text = "a".repeat(MAX_INPUT_CHARS + 1);
let (fired, result) = run_guard(&text, true, true);
assert!(result.is_err() && !fired, "in-flight first: silent noop");
let (fired, result) = run_guard(&text, true, false);
assert!(result.is_err() && fired, "toast fires over-limit first");
}
}