Skip to main content

omp_tui/components/
editor.rs

1use std::{cell::RefCell, rc::Rc};
2
3use omp_core::{Str, fmts};
4use serde_json::Value;
5use smallvec::SmallVec;
6use xutf::Text;
7
8use super::Img;
9use crate::{
10	BufferOutcome, EditBuffer,
11	component::{
12		Cached, Component, EventCtx, Flow, Hit, HitTag, IntoComponent, PaintCtx, Slot, next_slot,
13	},
14	context::{Charset, UiContext},
15	frame::{Color, Frame, Rect, Style},
16	input::{Key, Mouse, byte_at_column, sanitize_paste},
17	markup::Border,
18	props::{Prop, PropValue, Props},
19	syntax::{SyntaxRun, highlight_xml, xml_comment_state},
20};
21
22/// Focusable editable leaf used by [`EditorPane`].
23pub struct EditInput {
24	props:       Props,
25	slot:        Slot,
26	buffer:      EditBuffer,
27	attachments: Option<Attachments>,
28}
29
30impl EditInput {
31	/// Creates an empty editor.
32	pub fn new() -> Self {
33		Self {
34			props:       Props::new(),
35			slot:        next_slot(),
36			buffer:      EditBuffer::default(),
37			attachments: None,
38		}
39	}
40
41	/// Binds the composer's shared attachment queue: image path drops stage
42	/// automatically, large pastes collapse into atomic `<icon> #N` chips,
43	/// and deleting a chip hides its card until an undo restores it.
44	pub fn attachments(mut self, attachments: Attachments) -> Self {
45		self.attachments = Some(attachments);
46		self
47	}
48
49	/// Hides staged attachments whose chip left the buffer (an undo that
50	/// restores the chip re-shows them), returning whether anything
51	/// changed.
52	fn reconcile(&self, ctx: &UiContext) -> bool {
53		let Some(attachments) = &self.attachments else {
54			return false;
55		};
56		let text = self.buffer.text();
57		let ranges = self.buffer.atom_ranges();
58		attachments.set_visible(|attachment| {
59			let chip = chip_label(attachment, ctx.charset);
60			ranges
61				.iter()
62				.any(|&(start, end)| text.get(start..end) == Some(chip.as_str()))
63		})
64	}
65
66	#[allow(dead_code, reason = "acceptance-suite probe")]
67	pub(crate) const fn buffer(&self) -> &EditBuffer {
68		&self.buffer
69	}
70
71	#[allow(dead_code, reason = "acceptance-suite probe")]
72	pub(crate) const fn buffer_mut(&mut self) -> &mut EditBuffer {
73		&mut self.buffer
74	}
75
76	/// Sets one editor property, updating its buffer for `value`.
77	pub fn with(mut self, prop: Prop, value: impl Into<PropValue>) -> Self {
78		let value = value.into();
79		if prop == Prop::Value
80			&& let PropValue::Str(text) = &value
81		{
82			self.buffer = EditBuffer::new(text);
83		}
84		self.props.set(prop, value);
85		self
86	}
87
88	/// Sets one editor property from a string.
89	pub fn with_str(self, prop: Prop, value: &str) -> Self {
90		self.with(prop, value)
91	}
92
93	fn text_width(width: u16) -> u16 {
94		width.saturating_sub(2).max(1)
95	}
96
97	fn page_rows(ec: &EventCtx<'_>) -> usize {
98		usize::from(ec.view_rows.max(1))
99	}
100}
101
102impl Default for EditInput {
103	fn default() -> Self {
104		Self::new()
105	}
106}
107
108impl Component for EditInput {
109	fn props(&self) -> &Props {
110		&self.props
111	}
112
113	fn props_mut(&mut self) -> &mut Props {
114		&mut self.props
115	}
116
117	fn slot(&self) -> Slot {
118		self.slot
119	}
120
121	fn measure(&mut self, _ctx: &UiContext) -> (u16, u16) {
122		(20, 40)
123	}
124
125	fn height(&mut self, _ctx: &UiContext, _width: u16) -> u16 {
126		4
127	}
128
129	fn paint(&mut self, pc: &mut PaintCtx<'_>, rect: Rect) {
130		pc.hits
131			.push(Hit { rect, slot: self.slot, tag: HitTag::Press });
132		let focused = pc.focus == Some(self.slot);
133		let text = self.buffer.text();
134		let atoms = self.buffer.atom_ranges();
135		let rows = self
136			.buffer
137			.rows(Self::text_width(rect.width), usize::from(rect.height));
138		let rail = if focused {
139			Style::new().fg(pc.ctx.theme.accent)
140		} else {
141			Style::new().fg(pc.ctx.theme.muted)
142		};
143		let cursor_style = Style::new()
144			.fg(pc.ctx.theme.contrast)
145			.bg(pc.ctx.theme.accent);
146		let buffer_start = text.as_ptr() as usize;
147		let mut scanned = 0;
148		let mut in_comment = false;
149		for (row, content) in rows.iter().enumerate() {
150			let y = rect
151				.y
152				.saturating_add(u16::try_from(row).unwrap_or(u16::MAX));
153			if y >= pc.clip {
154				break;
155			}
156			let start = (content.text.as_ptr() as usize)
157				.saturating_sub(buffer_start)
158				.min(text.len());
159			in_comment = xml_comment_state(&text[scanned..start], in_comment);
160			let (runs, next_comment) = highlight_xml(content.text, &pc.ctx.theme, in_comment);
161			in_comment = next_comment;
162			scanned = start.saturating_add(content.text.len()).min(text.len());
163
164			// Chip styling wins over the XML runs it overlaps; the style
165			// derives from the FULL atom text, so a chip wrapped across
166			// rows keeps its color on every row.
167			let mut chips: SmallVec<(usize, usize, Style), 4> = SmallVec::new();
168			for &(atom_start, atom_end) in &atoms {
169				let from = atom_start.max(start);
170				let to = atom_end.min(scanned);
171				if from < to
172					&& let Some(style) = chip_style(&text[atom_start..atom_end])
173				{
174					chips.push((from - start, to - start, style));
175				}
176			}
177			let runs = overlay_chip_runs(&runs, &chips, content.text.len());
178
179			let x = pc.frame.put(rect.x, y, pc.ctx.charset.rail(), rail);
180			let cursor = focused
181				.then_some(content.cursor_column)
182				.flatten()
183				.map(|column| byte_at_column(content.text, column));
184			paint_xml_runs(pc.frame, x, y, content.text, &runs, cursor, cursor_style);
185		}
186	}
187
188	fn focusable(&self) -> bool {
189		true
190	}
191
192	fn key(&mut self, ec: &mut EventCtx<'_>, key: Key) -> Flow {
193		if matches!(key, Key::Up) && self.buffer.at_visual_start()
194			|| matches!(key, Key::Down) && self.buffer.at_visual_end()
195		{
196			return Flow::Skip;
197		}
198		if matches!(
199			self
200				.buffer
201				.handle(key, Self::text_width(ec.width), Self::page_rows(ec)),
202			BufferOutcome::Changed
203		) {
204			if self.reconcile(ec.ctx) {
205				// The pane's attachment band changed height outside this
206				// leaf's own box.
207				ec.request_layout();
208			}
209			Flow::Consumed
210		} else {
211			Flow::Skip
212		}
213	}
214
215	fn mouse(
216		&mut self,
217		ec: &mut EventCtx<'_>,
218		_tag: HitTag,
219		at: (u16, u16),
220		rect: Rect,
221		mouse: Mouse,
222	) -> Flow {
223		match mouse {
224			Mouse::Click => {
225				self.buffer.set_cursor_visual_row(
226					usize::from(at.1.saturating_sub(rect.y)),
227					at.0.saturating_sub(rect.x + 2),
228					Self::text_width(rect.width),
229				);
230				Flow::Consumed
231			},
232			Mouse::WheelUp | Mouse::WheelDown => {
233				let delta = if mouse == Mouse::WheelUp { -1 } else { 1 };
234				if self
235					.buffer
236					.scroll_rows(delta, Self::text_width(ec.width), Self::page_rows(ec))
237				{
238					Flow::Consumed
239				} else {
240					Flow::Skip
241				}
242			},
243			Mouse::RightClick
244			| Mouse::MiddleClick
245			| Mouse::Move
246			| Mouse::Drag
247			| Mouse::Release
248			| Mouse::WheelLeft
249			| Mouse::WheelRight => Flow::Skip,
250		}
251	}
252
253	fn paste(&mut self, ec: &mut EventCtx<'_>, text: &str) -> Flow {
254		if let Some(attachments) = &self.attachments {
255			let paths = crate::paste::dropped_paths(text);
256			// Requiring real files keeps prose that merely resembles a path out of the
257			// band.
258			if !paths.is_empty()
259				&& paths.iter().all(|path| {
260					crate::paste::is_image_path(path) && std::path::Path::new(path.as_str()).exists()
261				}) {
262				for path in paths {
263					let attachment = attachments.push_image(path.clone());
264					let chip = chip_label(&attachment, ec.ctx.charset);
265					let _ = self.buffer.insert_reference(&chip, path.as_str());
266					let _ = self.buffer.insert_text(" ");
267				}
268				ec.request_layout();
269				return Flow::Consumed;
270			}
271		}
272		if let Some(attachments) = &self.attachments
273			&& collapses_to_chip(text)
274		{
275			let attachment = attachments.push_text(text);
276			let chip = chip_label(&attachment, ec.ctx.charset);
277			let payload = sanitize_paste(text);
278			let _ = self.buffer.insert_reference(&chip, &payload);
279			let _ = self.buffer.insert_text(" ");
280			ec.request_layout();
281			return Flow::Consumed;
282		}
283		let sanitized = sanitize_paste(text);
284		let path_prefix = matches!(sanitized.as_bytes().first(), Some(b'/' | b'~' | b'.'));
285		let before_is_word = self.buffer.text()[..self.buffer.cursor()]
286			.chars()
287			.next_back()
288			.is_some_and(|ch| ch.is_alphanumeric() || ch == '_');
289		if path_prefix && before_is_word {
290			let _ = self.buffer.insert_text(" ");
291		}
292		if matches!(self.buffer.insert_text(&sanitized), BufferOutcome::Changed) {
293			Flow::Consumed
294		} else {
295			Flow::Skip
296		}
297	}
298
299	fn paste_raw(&mut self, _ec: &mut EventCtx<'_>, text: &str) -> Flow {
300		// Verbatim insertion (pi's raw-paste binding): the text stays inline
301		// and editable — no attachment staging, no large-paste chip, no
302		// auto-spacing. Sanitization still applies inside `insert_text`.
303		if matches!(self.buffer.insert_text(text), BufferOutcome::Changed) {
304			Flow::Consumed
305		} else {
306			Flow::Skip
307		}
308	}
309
310	fn value(&self, out: &mut serde_json::Map<String, Value>) {
311		if let Some(id) = self.props.id() {
312			out.insert(id.to_string(), Value::String(self.buffer.expanded_text()));
313		}
314	}
315
316	fn set_text(&mut self, _ctx: &UiContext, text: Str) -> bool {
317		if self.buffer.text() == text {
318			return false;
319		}
320		self.buffer = EditBuffer::new(&text);
321		true
322	}
323}
324
325/// Attachment preview thumbnail content, in cells.
326const PREVIEW_COLS: u16 = 12;
327const PREVIEW_ROWS: u16 = 4;
328/// Blank columns between adjacent preview frames.
329const PREVIEW_GAP: u16 = 2;
330/// One preview frame: thumbnail content plus its colored border.
331const PREVIEW_BOX_COLS: u16 = PREVIEW_COLS + 2;
332const PREVIEW_BOX_ROWS: u16 = PREVIEW_ROWS + 2;
333/// Identity palette cycled by marker number; see [`attachment_color`].
334const ATTACHMENT_COLORS: [Color; 6] = [
335	Color::Rgb(255, 179, 102),
336	Color::Rgb(125, 207, 255),
337	Color::Rgb(189, 147, 249),
338	Color::Rgb(105, 220, 158),
339	Color::Rgb(255, 141, 188),
340	Color::Rgb(240, 223, 120),
341];
342
343/// Identity color for attachment marker `N` (1-based).
344///
345/// The preview frame and any host-rendered reference chip share it, so an
346/// attachment stays recognizable from composer to transcript.
347pub const fn attachment_color(marker: usize) -> Color {
348	ATTACHMENT_COLORS[marker.saturating_sub(1) % ATTACHMENT_COLORS.len()]
349}
350
351/// The composer chip text for one attachment: `<icon> #N` with the tier's
352/// image or text-file glyph.
353///
354/// Hosts insert it as an atomic reference (see
355/// [`crate::EditBuffer::insert_reference`]); [`EditInput`] does so
356/// automatically for staged image path drops and large paste cards.
357pub fn chip_label(attachment: &Attachment, charset: Charset) -> Str {
358	let icon = charset.icon(match attachment.content {
359		AttachmentContent::Image { .. } => crate::Icon::Image,
360		AttachmentContent::Text { .. } => crate::Icon::TextFile,
361	});
362	fmts!("{icon} #{}", attachment.marker)
363}
364
365/// Chip style for an atomic marker: a trailing `#N` selects the marker's
366/// identity color. `None` leaves other atoms on their base styling.
367fn chip_style(marker: &str) -> Option<Style> {
368	let digits = &marker[marker.rfind('#')? + 1..];
369	if digits.is_empty() || !digits.bytes().all(|byte| byte.is_ascii_digit()) {
370		return None;
371	}
372	let marker: usize = digits.parse().ok()?;
373	(marker > 0).then(|| Style::new().fg(attachment_color(marker)).bold())
374}
375
376/// Whether a paste is large enough to collapse into an attachment chip.
377fn collapses_to_chip(text: &str) -> bool {
378	text.len() > 1000 || text.bytes().filter(|byte| *byte == b'\n').count() >= 10
379}
380
381/// Splices chip-styled runs over one row's syntax runs; chips win where
382/// they overlap. `len` is the row's byte length.
383fn overlay_chip_runs(
384	runs: &[SyntaxRun],
385	chips: &[(usize, usize, Style)],
386	len: usize,
387) -> SmallVec<SyntaxRun, 16> {
388	let mut merged: SmallVec<SyntaxRun, 16> = SmallVec::new();
389	if chips.is_empty() {
390		merged.extend_from_slice(runs);
391		return merged;
392	}
393	let base = |at: usize| {
394		runs
395			.iter()
396			.find(|run| run.start <= at && at < run.end)
397			.map_or_else(
398				|| {
399					let next = runs
400						.iter()
401						.map(|run| run.start)
402						.filter(|start| *start > at)
403						.min()
404						.unwrap_or(len);
405					(next, Style::new())
406				},
407				|run| (run.end, run.style),
408			)
409	};
410	fn emit(
411		base: &impl Fn(usize) -> (usize, Style),
412		from: usize,
413		to: usize,
414		merged: &mut SmallVec<SyntaxRun, 16>,
415	) {
416		let mut at = from;
417		while at < to {
418			let (run_end, style) = base(at);
419			let end = run_end.min(to);
420			merged.push(SyntaxRun { start: at, end, style });
421			at = end;
422		}
423	}
424	let mut at = 0;
425	for &(start, end, style) in chips {
426		emit(&base, at, start, &mut merged);
427		merged.push(SyntaxRun { start, end, style });
428		at = end;
429	}
430	emit(&base, at, len, &mut merged);
431	merged
432}
433
434/// One staged composer attachment.
435#[derive(Clone)]
436pub struct Attachment {
437	/// What the attachment holds and how its preview card renders.
438	pub content: AttachmentContent,
439	/// 1-based marker number (`#N`), stable until the queue is drained.
440	pub marker:  usize,
441	/// Identity color shared by the preview frame and chip highlights.
442	pub color:   Color,
443}
444
445/// Content behind one [`Attachment`].
446#[derive(Clone)]
447pub enum AttachmentContent {
448	/// An image staged from a file source.
449	Image {
450		/// Image source path.
451		source:     Str,
452		/// Pixel dimensions probed from the source header, when recognized.
453		dimensions: Option<(u32, u32)>,
454	},
455	/// Pasted text collapsed out of the composer.
456	Text {
457		/// Leading rows previewed inside the card, pre-clipped to the frame.
458		snippet: Str,
459		/// Logical line count of the paste.
460		lines:   usize,
461		/// Character count of the paste.
462		chars:   usize,
463	},
464}
465
466/// Shared handle to the attachments staged on an [`EditorPane`] composer.
467///
468/// The composer's owner keeps a clone (see [`EditorPane::attachments`]),
469/// stages images with [`Attachments::push_image`] and collapsed pastes with
470/// [`Attachments::push_text`], and drains the queue on submit with
471/// [`Attachments::take`]. The pane renders one framed card per visible
472/// attachment above its status band, tinted with the attachment's identity
473/// color and captioned with its `#N` marker plus pixel resolution or paste
474/// size.
475///
476/// [`Attachments::set_visible`] reconciles the band with the composer text:
477/// an attachment whose inline reference was deleted hides — and returns on
478/// undo — without losing its marker number.
479///
480/// Mutations change the pane's height out of band, so the owner triggers a
481/// relayout afterwards (e.g. [`crate::Ui::resize`] at the current width).
482#[derive(Clone, Default)]
483pub struct Attachments {
484	state: Rc<RefCell<AttachmentState>>,
485}
486
487#[derive(Default)]
488struct AttachmentState {
489	staged:  Vec<Staged>,
490	/// Monotonic marker source; survives hides so numbers stay stable.
491	counter: usize,
492	version: u64,
493}
494
495struct Staged {
496	attachment: Attachment,
497	hidden:     bool,
498}
499
500impl Attachments {
501	/// Creates an empty attachment queue.
502	pub fn new() -> Self {
503		Self::default()
504	}
505
506	/// Stages an image source, probing its pixel dimensions from the file
507	/// header, and returns the staged descriptor.
508	pub fn push_image(&self, source: impl Into<Str>) -> Attachment {
509		let source = source.into();
510		let dimensions = probe_dimensions(source.as_str());
511		self.stage(AttachmentContent::Image { source, dimensions })
512	}
513
514	/// Stages pasted text collapsed out of the composer and returns the
515	/// staged descriptor.
516	pub fn push_text(&self, text: &str) -> Attachment {
517		let lines = text.bytes().filter(|byte| *byte == b'\n').count() + 1;
518		let chars = text.chars().count();
519		let mut snippet = String::new();
520		for (index, line) in text.split('\n').take(usize::from(PREVIEW_ROWS)).enumerate() {
521			if index > 0 {
522				snippet.push('\n');
523			}
524			snippet.push_str(&line[..byte_at_column(line, PREVIEW_COLS)]);
525		}
526		self.stage(AttachmentContent::Text { snippet: Str::from(snippet), lines, chars })
527	}
528
529	fn stage(&self, content: AttachmentContent) -> Attachment {
530		let mut state = self.state.borrow_mut();
531		state.counter += 1;
532		let attachment =
533			Attachment { content, marker: state.counter, color: attachment_color(state.counter) };
534		state
535			.staged
536			.push(Staged { attachment: attachment.clone(), hidden: false });
537		state.version += 1;
538		attachment
539	}
540
541	/// Drains the whole queue, restarting marker numbering, and returns
542	/// the visible attachments in marker order. Hidden descriptors — whose
543	/// inline references the user deleted — are discarded, never handed to
544	/// the host.
545	pub fn take(&self) -> Vec<Attachment> {
546		let mut state = self.state.borrow_mut();
547		if !state.staged.is_empty() {
548			state.version += 1;
549		}
550		state.counter = 0;
551		std::mem::take(&mut state.staged)
552			.into_iter()
553			.filter(|staged| !staged.hidden)
554			.map(|staged| staged.attachment)
555			.collect()
556	}
557
558	/// Shows exactly the attachments `visible` accepts and hides the rest
559	/// (ones whose inline reference was deleted), returning whether
560	/// anything changed. Hidden attachments stay staged, so an undo can
561	/// bring them back.
562	pub fn set_visible(&self, mut visible: impl FnMut(&Attachment) -> bool) -> bool {
563		let mut state = self.state.borrow_mut();
564		let mut changed = false;
565		for staged in &mut state.staged {
566			let hide = !visible(&staged.attachment);
567			changed |= staged.hidden != hide;
568			staged.hidden = hide;
569		}
570		if changed {
571			state.version += 1;
572		}
573		changed
574	}
575
576	/// Number of visible attachments.
577	pub fn len(&self) -> usize {
578		self
579			.state
580			.borrow()
581			.staged
582			.iter()
583			.filter(|staged| !staged.hidden)
584			.count()
585	}
586
587	/// Whether no attachment is visible.
588	pub fn is_empty(&self) -> bool {
589		self.len() == 0
590	}
591}
592
593/// Probes `source`'s pixel dimensions from its header bytes.
594fn probe_dimensions(source: &str) -> Option<(u32, u32)> {
595	let bytes = std::fs::read(source).ok()?;
596	let probed = crate::imagefmt::dimensions(&bytes)?;
597	Some((probed.width, probed.height))
598}
599
600/// Editor shell with replaceable editable content, status chrome, and an
601/// attachment preview band.
602pub struct EditorPane {
603	props:       Props,
604	slot:        Slot,
605	/// `[input, status?, previews..]`; previews start at
606	/// [`EditorPane::preview_start`].
607	children:    SmallVec<Cached, 2>,
608	has_status:  bool,
609	attachments: Attachments,
610	/// Attachment-state version the preview children were built from.
611	synced:      u64,
612	/// Preview band rectangle captured at place time; zero when empty.
613	band:        Rect,
614}
615
616impl EditorPane {
617	/// Creates an editor shell with a default [`EditInput`].
618	pub fn new() -> Self {
619		let attachments = Attachments::new();
620		let mut children = SmallVec::new();
621		children.push(Cached::new(Box::new(EditInput::new().attachments(attachments.clone()))));
622		Self {
623			props: Props::new(),
624			slot: next_slot(),
625			children,
626			has_status: false,
627			attachments,
628			synced: 0,
629			band: Rect::new(0, 0, 0, 0),
630		}
631	}
632
633	/// Replaces the editable leaf.
634	pub fn input(mut self, input: impl IntoComponent) -> Self {
635		self.children[0] = Cached::new(input.into_component());
636		self
637	}
638
639	/// Adds or replaces the status band above the editable content.
640	pub fn status(mut self, status: impl IntoComponent) -> Self {
641		let status = Cached::new(status.into_component());
642		if self.has_status {
643			self.children[1] = status;
644		} else {
645			self.children.insert(1, status);
646			self.has_status = true;
647		}
648		self
649	}
650
651	/// Shared handle to this composer's staged attachments.
652	pub fn attachments(&self) -> Attachments {
653		self.attachments.clone()
654	}
655
656	/// Index of the first image-preview child: input, then the optional
657	/// status.
658	fn preview_start(&self) -> usize {
659		1 + usize::from(self.has_status)
660	}
661
662	/// Rows of the preview band: framed cards plus a blank spacer row
663	/// above the status line.
664	fn band_rows(&self) -> u16 {
665		if self.attachments.is_empty() {
666			0
667		} else {
668			PREVIEW_BOX_ROWS + 1
669		}
670	}
671
672	/// Rebuilds the image-preview children when the shared attachment
673	/// queue changed.
674	fn sync_attachments(&mut self) {
675		let state = self.attachments.state.borrow();
676		if state.version == self.synced {
677			return;
678		}
679		self.synced = state.version;
680		let keep = 1 + usize::from(self.has_status);
681		self.children.truncate(keep);
682		for staged in state.staged.iter().filter(|staged| !staged.hidden) {
683			if let AttachmentContent::Image { source, .. } = &staged.attachment.content {
684				self.children.push(Cached::new(Box::new(
685					Img::new()
686						.with(Prop::Src, source.clone())
687						.with(Prop::W, PREVIEW_COLS)
688						.with(Prop::H, PREVIEW_ROWS)
689						.with(Prop::Trim, true),
690				)));
691			}
692		}
693	}
694
695	/// Paints each attachment card: a rounded frame tinted with its
696	/// identity color, captioned `<icon> #N` on the top edge and the pixel
697	/// resolution or paste size on the bottom edge. Image cards hold a
698	/// thumbnail; paste cards preview the leading text.
699	fn paint_previews(&mut self, pc: &mut PaintCtx<'_>) {
700		if self.band.height == 0 {
701			return;
702		}
703		let (tl, tr, bl, br, horizontal, vertical) = pc.ctx.charset.border(Border::Round);
704		let handle = self.attachments.clone();
705		let state = handle.state.borrow();
706		let right_limit = self.band.x.saturating_add(self.band.width);
707		let top = self.band.y;
708		let bottom = top.saturating_add(PREVIEW_BOX_ROWS.saturating_sub(1));
709		let snippet_style = Style::new().fg(pc.ctx.theme.muted);
710		let mut glyph = [0_u8; 4];
711		let mut x = self.band.x;
712		let mut image_child = self.preview_start();
713		for staged in state.staged.iter().filter(|staged| !staged.hidden) {
714			let attachment = &staged.attachment;
715			if x.saturating_add(PREVIEW_BOX_COLS) > right_limit {
716				break;
717			}
718			let line = Style::new().fg(attachment.color);
719			let label = line.bold();
720			let (icon, size) = match &attachment.content {
721				AttachmentContent::Image { dimensions, .. } => (
722					pc.ctx.charset.icon(crate::Icon::Image),
723					dimensions.map(|(width, height)| fmts!("{width}x{height}")),
724				),
725				AttachmentContent::Text { lines, chars, .. } => (
726					pc.ctx.charset.icon(crate::Icon::TextFile),
727					Some(if *lines > 1 {
728						fmts!("+{lines} lines")
729					} else {
730						fmts!("{chars} chars")
731					}),
732				),
733			};
734			let name = fmts!("{icon} #{}", attachment.marker);
735			frame_caption_row(pc, x, top, PREVIEW_BOX_COLS, (tl, tr, horizontal), &name, line, label);
736			frame_caption_row(
737				pc,
738				x,
739				bottom,
740				PREVIEW_BOX_COLS,
741				(bl, br, horizontal),
742				size.as_deref().unwrap_or(""),
743				line,
744				label,
745			);
746			let rail = vertical.encode_utf8(&mut glyph);
747			let frame_right = x.saturating_add(PREVIEW_BOX_COLS.saturating_sub(1));
748			for row in top.saturating_add(1)..bottom {
749				if row >= pc.clip {
750					break;
751				}
752				pc.frame.put(x, row, rail, line);
753				pc.frame.put(frame_right, row, rail, line);
754			}
755			match &attachment.content {
756				AttachmentContent::Image { .. } => {
757					if let Some(child) = self.children.get_mut(image_child) {
758						if child.visible {
759							child.paint(pc);
760						}
761						image_child += 1;
762					}
763				},
764				AttachmentContent::Text { snippet, .. } => {
765					for (offset, text) in snippet.as_str().split('\n').enumerate() {
766						let y = top
767							.saturating_add(1)
768							.saturating_add(u16::try_from(offset).unwrap_or(u16::MAX));
769						if y >= bottom || y >= pc.clip {
770							break;
771						}
772						pc.frame.put(x.saturating_add(1), y, text, snippet_style);
773					}
774				},
775			}
776			x = x
777				.saturating_add(PREVIEW_BOX_COLS)
778				.saturating_add(PREVIEW_GAP);
779		}
780	}
781
782	/// Sets one editor-shell property.
783	pub fn with(mut self, prop: Prop, value: impl Into<PropValue>) -> Self {
784		let value = value.into();
785		if matches!(prop, Prop::Id | Prop::Value) {
786			self.children[0]
787				.comp_mut()
788				.props_mut()
789				.set(prop, value.clone());
790			if prop == Prop::Value
791				&& let PropValue::Str(text) = &value
792			{
793				self.children[0]
794					.comp_mut()
795					.set_text(&UiContext::default(), text.clone());
796			}
797		} else {
798			self.props.set(prop, value);
799		}
800		self
801	}
802
803	/// Sets one editor-shell property from a string.
804	pub fn with_str(self, prop: Prop, value: &str) -> Self {
805		self.with(prop, value)
806	}
807
808	#[cfg(test)]
809	pub(crate) fn buffer(&self) -> &EditBuffer {
810		self.children[0]
811			.comp()
812			.downcast_ref::<EditInput>()
813			.expect("default editor input was replaced")
814			.buffer()
815	}
816
817	#[cfg(test)]
818	pub(crate) fn buffer_mut(&mut self) -> &mut EditBuffer {
819		self.children[0]
820			.comp_mut()
821			.downcast_mut::<EditInput>()
822			.expect("default editor input was replaced")
823			.buffer_mut()
824	}
825}
826
827impl Default for EditorPane {
828	fn default() -> Self {
829		Self::new()
830	}
831}
832
833impl Component for EditorPane {
834	fn props(&self) -> &Props {
835		&self.props
836	}
837
838	fn props_mut(&mut self) -> &mut Props {
839		&mut self.props
840	}
841
842	fn slot(&self) -> Slot {
843		self.slot
844	}
845
846	fn children(&self) -> &[Cached] {
847		&self.children
848	}
849
850	fn children_mut(&mut self) -> &mut [Cached] {
851		&mut self.children
852	}
853
854	fn ring(&self, out: &mut Vec<Slot>) {
855		self.children[0].comp().ring(out);
856	}
857
858	fn measure(&mut self, ctx: &UiContext) -> (u16, u16) {
859		self.sync_attachments();
860		self.children[0].measure(ctx)
861	}
862
863	fn height(&mut self, ctx: &UiContext, width: u16) -> u16 {
864		self.sync_attachments();
865		let input = self.children[0].height(ctx, width);
866		let status = u16::from(self.has_status && self.props.border().is_none());
867		input
868			.saturating_add(status)
869			.saturating_add(self.band_rows())
870	}
871
872	fn place(&mut self, ctx: &UiContext, rect: Rect) {
873		self.sync_attachments();
874		let bordered = self.props.border().is_some();
875		let band = self.band_rows();
876		let status_height = u16::from(self.has_status && !bordered);
877		let top = band.saturating_add(status_height);
878		self.children[0].place(
879			ctx,
880			Rect::new(rect.x, rect.y.saturating_add(top), rect.width, rect.height.saturating_sub(top)),
881		);
882		if self.has_status {
883			let (x, y, width) = if bordered {
884				(rect.x.saturating_sub(1), rect.y.saturating_sub(1), rect.width.saturating_add(2))
885			} else {
886				(rect.x, rect.y.saturating_add(band), rect.width)
887			};
888			let status = &mut self.children[1];
889			let _ = status.measure(ctx);
890			let _ = status.height(ctx, width);
891			status.place(ctx, Rect::new(x, y, width, 1));
892		}
893		self.band =
894			Rect::new(rect.x, rect.y, rect.width, if band > 0 { PREVIEW_BOX_ROWS } else { 0 });
895		let right = rect.x.saturating_add(rect.width);
896		let handle = self.attachments.clone();
897		let state = handle.state.borrow();
898		let mut x = rect.x;
899		let mut image_child = self.preview_start();
900		for staged in state.staged.iter().filter(|staged| !staged.hidden) {
901			let fits = x.saturating_add(PREVIEW_BOX_COLS) <= right;
902			if matches!(staged.attachment.content, AttachmentContent::Image { .. })
903				&& let Some(child) = self.children.get_mut(image_child)
904			{
905				image_child += 1;
906				child.visible = fits;
907				if fits {
908					let _ = child.measure(ctx);
909					let _ = child.height(ctx, PREVIEW_COLS);
910					child.place(
911						ctx,
912						Rect::new(
913							x.saturating_add(1),
914							rect.y.saturating_add(1),
915							PREVIEW_COLS,
916							PREVIEW_ROWS,
917						),
918					);
919				}
920			}
921			if fits {
922				x = x
923					.saturating_add(PREVIEW_BOX_COLS)
924					.saturating_add(PREVIEW_GAP);
925			}
926		}
927	}
928
929	fn paint(&mut self, pc: &mut PaintCtx<'_>, rect: Rect) {
930		if self.children[0].rect.width == 0 {
931			self.place(pc.ctx, rect);
932		}
933		self.children[0].paint(pc);
934		if self.has_status {
935			let status = &mut self.children[1];
936			if self.props.border().is_some() {
937				// The status owns the top row, so remove the border before
938				// painting its shaped band.
939				pc.frame.fill(status.rect, Style::new());
940			}
941			status.paint(pc);
942		}
943		self.paint_previews(pc);
944	}
945
946	fn value(&self, out: &mut serde_json::Map<String, Value>) {
947		self.children[0].comp().value(out);
948	}
949
950	fn set_text(&mut self, ctx: &UiContext, text: Str) -> bool {
951		self.children[0].comp_mut().set_text(ctx, text)
952	}
953}
954
955/// Paints one preview-frame edge: corners, rule fill, and an optional
956/// centered caption set off by single spaces.
957fn frame_caption_row(
958	pc: &mut PaintCtx<'_>,
959	x: u16,
960	y: u16,
961	width: u16,
962	(left, right, horizontal): (char, char, char),
963	caption: &str,
964	line: Style,
965	label: Style,
966) {
967	if y >= pc.clip || width < 2 {
968		return;
969	}
970	let mut glyph = [0_u8; 4];
971	let right_x = x.saturating_add(width.saturating_sub(1));
972	let mut at = pc.frame.put(x, y, left.encode_utf8(&mut glyph), line);
973	let caption_width = u16::try_from(xutf::width_str(caption)).unwrap_or(u16::MAX);
974	if !caption.is_empty() && caption_width.saturating_add(2) <= width.saturating_sub(2) {
975		let lead = (width.saturating_sub(2) - caption_width.saturating_add(2)) / 2;
976		let caption_x = at.saturating_add(lead);
977		for column in at..caption_x {
978			pc.frame
979				.put(column, y, horizontal.encode_utf8(&mut glyph), line);
980		}
981		at = pc.frame.put(caption_x, y, " ", line);
982		at = pc.frame.put(at, y, caption, label);
983		at = pc.frame.put(at, y, " ", line);
984	}
985	for column in at..right_x {
986		pc.frame
987			.put(column, y, horizontal.encode_utf8(&mut glyph), line);
988	}
989	pc.frame
990		.put(right_x, y, right.encode_utf8(&mut glyph), line);
991}
992
993fn paint_xml_range(
994	frame: &mut Frame,
995	mut x: u16,
996	y: u16,
997	text: &str,
998	runs: &[SyntaxRun],
999	start: usize,
1000	end: usize,
1001) -> u16 {
1002	for run in runs {
1003		let from = run.start.max(start);
1004		let to = run.end.min(end);
1005		if from < to {
1006			x = frame.put(x, y, &text[from..to], run.style);
1007		}
1008	}
1009	x
1010}
1011
1012fn paint_xml_runs(
1013	frame: &mut Frame,
1014	x: u16,
1015	y: u16,
1016	text: &str,
1017	runs: &[SyntaxRun],
1018
1019	cursor: Option<usize>,
1020	cursor_style: Style,
1021) {
1022	let Some(cursor) = cursor else {
1023		paint_xml_range(frame, x, y, text, runs, 0, text.len());
1024		return;
1025	};
1026	let mut x = paint_xml_range(frame, x, y, text, runs, 0, cursor);
1027	if cursor == text.len() {
1028		frame.put(x, y, " ", cursor_style);
1029		return;
1030	}
1031	let under = text[cursor..].graphemes().next().unwrap_or(" ");
1032	x = frame.put(x, y, under, cursor_style);
1033	paint_xml_range(frame, x, y, text, runs, cursor + under.len(), text.len());
1034}
1035
1036#[cfg(test)]
1037mod tests {
1038	use super::*;
1039	use crate::{
1040		Color, Ui,
1041		components::{Input, Segment, Status},
1042		context::{Charset, UiContext},
1043		frame::{Frame, Size},
1044		markup::Border,
1045		test_support::frame_row_text,
1046	};
1047	fn temp_drop_file(test: &str, name: &str, bytes: &[u8]) -> std::path::PathBuf {
1048		let dir = std::env::temp_dir().join(format!("omp-editor-drop-{test}-{}", std::process::id()));
1049		std::fs::create_dir_all(&dir).unwrap();
1050		let path = dir.join(name);
1051		std::fs::write(&path, bytes).unwrap();
1052		path
1053	}
1054
1055	fn editor_pane(ui: &Ui) -> &EditorPane {
1056		ui.root()
1057			.comp()
1058			.downcast_ref::<EditorPane>()
1059			.expect("UI root is an editor pane")
1060	}
1061
1062	struct GrowingInput {
1063		props: Props,
1064		slot:  Slot,
1065		rows:  u16,
1066	}
1067
1068	impl GrowingInput {
1069		fn new() -> Self {
1070			Self { props: Props::new(), slot: next_slot(), rows: 1 }
1071		}
1072	}
1073
1074	impl Component for GrowingInput {
1075		fn props(&self) -> &Props {
1076			&self.props
1077		}
1078
1079		fn props_mut(&mut self) -> &mut Props {
1080			&mut self.props
1081		}
1082
1083		fn slot(&self) -> Slot {
1084			self.slot
1085		}
1086
1087		fn measure(&mut self, _ctx: &UiContext) -> (u16, u16) {
1088			(1, 8)
1089		}
1090
1091		fn height(&mut self, _ctx: &UiContext, _width: u16) -> u16 {
1092			self.rows
1093		}
1094
1095		fn paint(&mut self, pc: &mut PaintCtx<'_>, rect: Rect) {
1096			pc.frame
1097				.set_cursor(rect.x, rect.y.saturating_add(self.rows.saturating_sub(1)));
1098		}
1099
1100		fn focusable(&self) -> bool {
1101			true
1102		}
1103
1104		fn key(&mut self, _ec: &mut EventCtx<'_>, key: Key) -> Flow {
1105			if key == Key::ShiftEnter {
1106				self.rows = self.rows.saturating_add(1);
1107				Flow::Consumed
1108			} else {
1109				Flow::Skip
1110			}
1111		}
1112	}
1113
1114	#[test]
1115	fn ui_routes_multiline_growth_to_the_editor_input_cache() {
1116		let mut ui =
1117			Ui::from_root(EditorPane::new().input(GrowingInput::new()), 14, UiContext::default());
1118		let initial_height = ui.height();
1119		ui.handle_key(Key::ShiftEnter);
1120		ui.handle_key(Key::ShiftEnter);
1121
1122		assert_eq!(ui.height(), initial_height.saturating_add(2));
1123		assert_eq!(ui.frame().size().height, ui.height());
1124		let (cursor_x, cursor_y) = ui.frame().cursor().expect("focused editor cursor");
1125		assert!(cursor_x < ui.frame().size().width);
1126		assert!(cursor_y < ui.frame().size().height);
1127	}
1128
1129	#[test]
1130	fn editor_status_replaces_top_border_with_rounded_band() {
1131		let ctx = UiContext { charset: Charset::NerdFont, ..UiContext::default() };
1132		let mut editor = Cached::new(Box::new(
1133			EditorPane::new().with(Prop::Border, Border::Round).status(
1134				Status::new()
1135					.with(Prop::Bg, "yellow")
1136					.segment(Segment::new().label("ready")),
1137			),
1138		));
1139		let height = editor.height(&ctx, 20);
1140		editor.place(&ctx, Rect::new(0, 0, 20, height));
1141		let mut frame = Frame::new(Size::new(20, height));
1142		let mut hits = Vec::new();
1143		editor.paint(&mut PaintCtx::new(&mut frame, &ctx, &mut hits, &mut Vec::new()));
1144
1145		assert_eq!(frame_row_text(&frame, 0), "\u{e0b6} ready \u{e0b0}");
1146		assert_eq!(frame.cell(0, 0).style.foreground_color(), Color::Rgb(255, 255, 0));
1147		assert_eq!(frame.cell(0, 0).style.background_color(), Color::Default);
1148		assert_eq!(frame.cell(9, 0).style.background_color(), Color::Default);
1149	}
1150
1151	#[test]
1152	fn unbordered_editor_status_reserves_a_borderless_header_row() {
1153		let ctx = UiContext { charset: Charset::NerdFont, ..UiContext::default() };
1154		let mut editor = Cached::new(Box::new(
1155			EditorPane::new().with(Prop::Value, "body").status(
1156				Status::new()
1157					.with(Prop::Bg, "yellow")
1158					.segment(Segment::new().label("ready")),
1159			),
1160		));
1161		let height = editor.height(&ctx, 20);
1162		editor.place(&ctx, Rect::new(0, 0, 20, height));
1163		let mut frame = Frame::new(Size::new(20, height));
1164		let mut hits = Vec::new();
1165		editor.paint(&mut PaintCtx::new(&mut frame, &ctx, &mut hits, &mut Vec::new()));
1166
1167		assert_eq!(frame_row_text(&frame, 0), "\u{e0b6} ready \u{e0b0}");
1168		assert!(frame_row_text(&frame, 1).contains("body"));
1169		for row in 0..height {
1170			let text = frame_row_text(&frame, row);
1171			assert!(
1172				!text
1173					.chars()
1174					.any(|glyph| matches!(glyph, '╭' | '╮' | '╰' | '╯' | '│' | '─')),
1175				"unexpected editor border on row {row}: {text}",
1176			);
1177		}
1178	}
1179
1180	#[test]
1181	fn editor_status_is_excluded_from_the_focus_ring() {
1182		let editor = EditorPane::new().status(Input::new());
1183		let mut ring = Vec::new();
1184		editor.ring(&mut ring);
1185		assert_eq!(ring, vec![editor.children[0].comp().slot()]);
1186	}
1187
1188	#[test]
1189	fn attachments_render_framed_previews_with_markers_and_resolution() {
1190		let dir = std::env::temp_dir().join(format!("omp-editor-attach-{}", std::process::id()));
1191		std::fs::create_dir_all(&dir).unwrap();
1192		let probed = dir.join("shot.png");
1193		let mut png = b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR".to_vec();
1194		png.extend(528_u32.to_be_bytes());
1195		png.extend(200_u32.to_be_bytes());
1196		std::fs::write(&probed, png).unwrap();
1197
1198		let ctx = UiContext::default();
1199		let pane = EditorPane::new()
1200			.with(Prop::Value, "body")
1201			.status(Status::new().segment(Segment::new().label("ready")));
1202		let attachments = pane.attachments();
1203		let mut editor = Cached::new(Box::new(pane));
1204		let base = editor.height(&ctx, 40);
1205
1206		let first = attachments.push_image(probed.to_str().expect("temp path is UTF-8"));
1207		assert_eq!(first.marker, 1);
1208		assert!(
1209			matches!(first.content, AttachmentContent::Image { dimensions: Some((528, 200)), .. }),
1210			"PNG header probes its resolution"
1211		);
1212		assert_eq!(first.color, attachment_color(1));
1213		assert_eq!(attachments.push_image("/nope/b.png").marker, 2);
1214		editor.invalidate();
1215		let height = editor.height(&ctx, 40);
1216		assert_eq!(
1217			height,
1218			base + PREVIEW_BOX_ROWS + 1,
1219			"band adds the framed previews plus the spacer row"
1220		);
1221		editor.place(&ctx, Rect::new(0, 0, 40, height));
1222		let mut frame = Frame::new(Size::new(40, height));
1223		let mut hits = Vec::new();
1224		editor.paint(&mut PaintCtx::new(&mut frame, &ctx, &mut hits, &mut Vec::new()));
1225
1226		let top = frame_row_text(&frame, 0);
1227		assert!(top.contains("#1"), "first frame caption missing: {top}");
1228		assert!(top.contains("#2"), "second frame caption missing: {top}");
1229		let bottom = frame_row_text(&frame, PREVIEW_BOX_ROWS - 1);
1230		assert!(bottom.contains("528x200"), "resolution caption missing: {bottom}");
1231		assert_eq!(frame.cell(0, 0).style.foreground_color(), attachment_color(1));
1232		assert_eq!(
1233			frame
1234				.cell(PREVIEW_BOX_COLS + PREVIEW_GAP, 0)
1235				.style
1236				.foreground_color(),
1237			attachment_color(2),
1238			"each frame is tinted with its own identity color"
1239		);
1240		assert_eq!(
1241			frame_row_text(&frame, PREVIEW_BOX_ROWS).trim(),
1242			"",
1243			"a spacer row separates the band from the status line"
1244		);
1245		assert!(frame_row_text(&frame, PREVIEW_BOX_ROWS + 1).contains("ready"));
1246		assert!(frame_row_text(&frame, PREVIEW_BOX_ROWS + 2).contains("body"));
1247
1248		assert_eq!(attachments.take().len(), 2);
1249		editor.invalidate();
1250		assert_eq!(editor.height(&ctx, 40), base, "taking attachments collapses the band");
1251		std::fs::remove_dir_all(&dir).ok();
1252	}
1253
1254	#[test]
1255	fn attachment_previews_hide_when_the_composer_is_too_narrow() {
1256		let ctx = UiContext::default();
1257		let pane = EditorPane::new();
1258		let attachments = pane.attachments();
1259		attachments.push_image("/nope/a.png");
1260		attachments.push_image("/nope/b.png");
1261		let mut editor = Cached::new(Box::new(pane));
1262		let height = editor.height(&ctx, 20);
1263		editor.place(&ctx, Rect::new(0, 0, 20, height));
1264		let mut frame = Frame::new(Size::new(20, height));
1265		let mut hits = Vec::new();
1266		editor.paint(&mut PaintCtx::new(&mut frame, &ctx, &mut hits, &mut Vec::new()));
1267
1268		let captions = frame_row_text(&frame, 0);
1269		assert!(captions.contains("#1"), "caption row: {captions}");
1270		assert!(!captions.contains("#2"), "overflowing preview must stay hidden: {captions}");
1271	}
1272
1273	#[test]
1274	fn paste_cards_preview_leading_text_with_size_caption() {
1275		let ctx = UiContext::default();
1276		let pane = EditorPane::new();
1277		let attachments = pane.attachments();
1278		let paste = (0..12)
1279			.map(|n| format!("line{n}"))
1280			.collect::<Vec<_>>()
1281			.join("\n");
1282		let card = attachments.push_text(&paste);
1283		assert_eq!(card.marker, 1);
1284		assert!(matches!(card.content, AttachmentContent::Text { lines: 12, .. }));
1285
1286		let mut editor = Cached::new(Box::new(pane));
1287		let height = editor.height(&ctx, 40);
1288		editor.place(&ctx, Rect::new(0, 0, 40, height));
1289		let mut frame = Frame::new(Size::new(40, height));
1290		let mut hits = Vec::new();
1291		editor.paint(&mut PaintCtx::new(&mut frame, &ctx, &mut hits, &mut Vec::new()));
1292
1293		assert!(frame_row_text(&frame, 0).contains("#1"));
1294		assert!(frame_row_text(&frame, 1).contains("line0"), "card previews the paste text");
1295		assert!(
1296			frame_row_text(&frame, PREVIEW_BOX_ROWS - 1).contains("+12 lines"),
1297			"bottom edge captions the paste size"
1298		);
1299	}
1300
1301	#[test]
1302	fn quoted_image_path_drop_stages_a_reference_chip() {
1303		let path = temp_drop_file("quoted", "drop test.png", b"\x89PNG\r\n\x1a\n");
1304		let normalized = path.to_str().expect("temp path is UTF-8");
1305		let pasted = format!("'{normalized}'");
1306		let pane = EditorPane::new().with(Prop::Id, "composer");
1307		let attachments = pane.attachments();
1308		let mut ui = Ui::from_root(pane, 60, UiContext::default());
1309		ui.focus_first();
1310
1311		ui.handle_paste(&pasted);
1312
1313		assert_eq!(attachments.len(), 1);
1314		let visible = editor_pane(&ui).buffer().text();
1315		assert!(visible.contains("#1"));
1316		assert!(!visible.contains(&pasted));
1317		assert!(visible.ends_with(' '));
1318		assert_eq!(editor_pane(&ui).buffer().expanded_text(), format!("{normalized} "));
1319		std::fs::remove_dir_all(path.parent().unwrap()).ok();
1320	}
1321
1322	#[test]
1323	fn file_url_image_drop_stages_its_normalized_path() {
1324		let path = temp_drop_file("file-url", "url drop.png", b"\x89PNG\r\n\x1a\n");
1325		let normalized = path.to_str().expect("temp path is UTF-8");
1326		let pasted = format!("file://{}", normalized.replace(' ', "%20"));
1327		let pane = EditorPane::new().with(Prop::Id, "composer");
1328		let attachments = pane.attachments();
1329		let mut ui = Ui::from_root(pane, 60, UiContext::default());
1330		ui.focus_first();
1331
1332		ui.handle_paste(&pasted);
1333
1334		assert_eq!(attachments.len(), 1);
1335		assert_eq!(editor_pane(&ui).buffer().expanded_text(), format!("{normalized} "));
1336		std::fs::remove_dir_all(path.parent().unwrap()).ok();
1337	}
1338
1339	#[test]
1340	fn escaped_image_path_drop_stages_chips_in_order() {
1341		let first = temp_drop_file("escaped", "drop one.png", b"\x89PNG\r\n\x1a\n");
1342		let second = temp_drop_file("escaped", "drop two.gif", b"GIF89a");
1343		let first_text = first.to_str().expect("temp path is UTF-8");
1344		let second_text = second.to_str().expect("temp path is UTF-8");
1345		let pasted =
1346			format!("{} {}", first_text.replace(' ', "\\ "), second_text.replace(' ', "\\ "));
1347		let pane = EditorPane::new().with(Prop::Id, "composer");
1348		let attachments = pane.attachments();
1349		let mut ui = Ui::from_root(pane, 60, UiContext::default());
1350		ui.focus_first();
1351
1352		ui.handle_paste(&pasted);
1353
1354		assert_eq!(attachments.len(), 2);
1355		let visible = editor_pane(&ui).buffer().text();
1356		assert!(visible.find("#1").unwrap() < visible.find("#2").unwrap());
1357		assert_eq!(editor_pane(&ui).buffer().expanded_text(), format!("{first_text} {second_text} "));
1358		std::fs::remove_dir_all(first.parent().unwrap()).ok();
1359	}
1360
1361	#[test]
1362	fn missing_image_path_drop_remains_plain_text() {
1363		let path = std::env::temp_dir()
1364			.join(format!("omp-editor-drop-missing-{}", std::process::id()))
1365			.join("missing image.png");
1366		std::fs::remove_file(&path).ok();
1367		let pasted = format!("'{}'", path.to_str().expect("temp path is UTF-8"));
1368		let pane = EditorPane::new().with(Prop::Id, "composer");
1369		let attachments = pane.attachments();
1370		let mut ui = Ui::from_root(pane, 60, UiContext::default());
1371		ui.focus_first();
1372
1373		ui.handle_paste(&pasted);
1374
1375		assert!(attachments.is_empty());
1376		assert_eq!(editor_pane(&ui).buffer().text(), pasted);
1377	}
1378
1379	#[test]
1380	fn existing_non_image_path_drop_remains_plain_text() {
1381		let path = temp_drop_file("non-image", "notes.txt", b"not an image");
1382		let pasted = path.to_str().expect("temp path is UTF-8");
1383		let pane = EditorPane::new().with(Prop::Id, "composer");
1384		let attachments = pane.attachments();
1385		let mut ui = Ui::from_root(pane, 60, UiContext::default());
1386		ui.focus_first();
1387
1388		ui.handle_paste(pasted);
1389
1390		assert!(attachments.is_empty());
1391		assert_eq!(editor_pane(&ui).buffer().text(), pasted);
1392		std::fs::remove_dir_all(path.parent().unwrap()).ok();
1393	}
1394
1395	#[test]
1396	fn image_path_drop_without_attachment_binding_remains_plain_text() {
1397		let path = temp_drop_file("unbound", "drop test.png", b"\x89PNG\r\n\x1a\n");
1398		let pasted = format!("'{}'", path.to_str().expect("temp path is UTF-8"));
1399		let mut ui =
1400			Ui::from_root(EditInput::new().with(Prop::Id, "composer"), 60, UiContext::default());
1401		ui.focus_first();
1402
1403		ui.handle_paste(&pasted);
1404
1405		let input = ui
1406			.root()
1407			.comp()
1408			.downcast_ref::<EditInput>()
1409			.expect("UI root is an editor input");
1410		assert_eq!(input.buffer().text(), pasted);
1411		std::fs::remove_dir_all(path.parent().unwrap()).ok();
1412	}
1413
1414	#[test]
1415	fn plain_path_paste_separates_from_a_preceding_word_only() {
1416		let mut after_word = Ui::from_root(
1417			EditInput::new()
1418				.with(Prop::Id, "composer")
1419				.with(Prop::Value, "word"),
1420			40,
1421			UiContext::default(),
1422		);
1423		after_word.focus_first();
1424		after_word.handle_paste("/tmp");
1425		assert_eq!(after_word.values()["composer"], Value::String("word /tmp".to_owned()));
1426
1427		let mut after_space = Ui::from_root(
1428			EditInput::new()
1429				.with(Prop::Id, "composer")
1430				.with(Prop::Value, "word "),
1431			40,
1432			UiContext::default(),
1433		);
1434		after_space.focus_first();
1435		after_space.handle_paste("/tmp");
1436		assert_eq!(after_space.values()["composer"], Value::String("word /tmp".to_owned()));
1437	}
1438
1439	#[test]
1440	fn hidden_attachments_keep_markers_but_never_reach_take() {
1441		let attachments = Attachments::new();
1442		attachments.push_image("/nope/a.png");
1443		attachments.push_image("/nope/b.png");
1444		assert!(attachments.set_visible(|attachment| attachment.marker != 1));
1445		assert_eq!(attachments.len(), 1, "hiding drops the visible count");
1446		assert_eq!(attachments.push_image("/nope/c.png").marker, 3, "markers stay stable");
1447
1448		// Undo made the first reference reappear.
1449		assert!(attachments.set_visible(|_| true));
1450		assert_eq!(attachments.len(), 3);
1451
1452		// Deleted again; the drain must never hand it to the host.
1453		assert!(attachments.set_visible(|attachment| attachment.marker != 1));
1454		let taken = attachments.take();
1455		assert_eq!(
1456			taken.iter().map(|a| a.marker).collect::<Vec<_>>(),
1457			vec![2, 3],
1458			"take returns only visible attachments"
1459		);
1460		assert!(attachments.is_empty());
1461		assert_eq!(attachments.push_image("/nope/d.png").marker, 1, "numbering restarts");
1462	}
1463
1464	#[test]
1465	fn default_editor_collapses_large_pastes_into_atomic_chip_cards() {
1466		let mut ui = Ui::from_root(
1467			EditorPane::new()
1468				.with(Prop::Id, "composer")
1469				.status(Status::new().segment(Segment::new().label("ready"))),
1470			40,
1471			UiContext::default(),
1472		);
1473		ui.focus_first();
1474		let base = ui.height();
1475		let paste = (0..12)
1476			.map(|n| format!("line{n}"))
1477			.collect::<Vec<_>>()
1478			.join("\n");
1479		ui.handle_paste(&paste);
1480		assert_eq!(
1481			ui.height(),
1482			base + PREVIEW_BOX_ROWS + 1,
1483			"a routed paste grows the pane's band without a manual relayout"
1484		);
1485		assert!(frame_row_text(ui.frame(), 0).contains("#1"));
1486		assert!(frame_row_text(ui.frame(), PREVIEW_BOX_ROWS - 1).contains("+12 lines"));
1487
1488		// The chip paints in its identity color inside the input row.
1489		let input_row = PREVIEW_BOX_ROWS + 2;
1490		let text = frame_row_text(ui.frame(), input_row);
1491		let hash = text.find('#').expect("chip in the input row");
1492		let column = u16::try_from(xutf::width_str(&text[..hash])).expect("narrow row");
1493		assert_eq!(ui.frame().cell(column, input_row).style.foreground_color(), attachment_color(1));
1494
1495		// Backspace over the trailing space, then the chip: one atomic unit
1496		// whose removal collapses the band through the same event path.
1497		ui.handle_key(Key::Backspace);
1498		ui.handle_key(Key::Backspace);
1499		assert_eq!(ui.height(), base, "deleting the chip collapses the band");
1500		assert_eq!(
1501			ui.values().get("composer").and_then(Value::as_str),
1502			Some(""),
1503			"a deleted paste never reaches the submitted value"
1504		);
1505
1506		// Undo restores the chip, its card, and the expanded payload.
1507		ui.handle_key(Key::Ctrl('_'));
1508		assert_eq!(ui.height(), base + PREVIEW_BOX_ROWS + 1, "undo restores the band");
1509		let values = ui.values();
1510		assert_eq!(
1511			values
1512				.get("composer")
1513				.and_then(Value::as_str)
1514				.map(|value| value.trim_end().to_owned()),
1515			Some(paste),
1516			"the restored chip expands back to the pasted text"
1517		);
1518	}
1519
1520	#[test]
1521	fn raw_paste_bypasses_chips_and_drop_classification() {
1522		let dir = std::env::temp_dir().join(format!("omp-tui-raw-paste-{}", std::process::id()));
1523		std::fs::create_dir_all(&dir).unwrap();
1524		let path = dir.join("raw.png");
1525		std::fs::write(&path, b"\x89PNG\r\n\x1a\n\0\0\0\0").unwrap();
1526
1527		let mut ui = Ui::from_root(
1528			EditorPane::new()
1529				.with(Prop::Id, "composer")
1530				.status(Status::new().segment(Segment::new().label("ready"))),
1531			40,
1532			UiContext::default(),
1533		);
1534		ui.focus_first();
1535		let base = ui.height();
1536
1537		// An existing image path inserts as text instead of staging an
1538		// attachment (Ctrl+Shift+V contract: verbatim insertion).
1539		let path_text = path.to_str().expect("temp path is UTF-8").to_owned();
1540		ui.handle_paste_raw(&path_text);
1541		assert_eq!(ui.height(), base, "no attachment band appears");
1542		assert_eq!(
1543			ui.values().get("composer").and_then(Value::as_str),
1544			Some(path_text.as_str()),
1545			"the path stays inline text"
1546		);
1547
1548		// Large text stays inline and editable instead of collapsing.
1549		let mut ui = Ui::from_root(
1550			EditorPane::new()
1551				.with(Prop::Id, "composer")
1552				.status(Status::new().segment(Segment::new().label("ready"))),
1553			40,
1554			UiContext::default(),
1555		);
1556		ui.focus_first();
1557		let base = ui.height();
1558		let big = (0..12)
1559			.map(|n| format!("line{n}"))
1560			.collect::<Vec<_>>()
1561			.join("\n");
1562		ui.handle_paste_raw(&big);
1563		assert_eq!(ui.height(), base, "no chip card band appears");
1564		assert_eq!(
1565			ui.values().get("composer").and_then(Value::as_str),
1566			Some(big.as_str()),
1567			"the full text stays inline"
1568		);
1569		std::fs::remove_dir_all(&dir).ok();
1570	}
1571}