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
22pub struct EditInput {
24 props: Props,
25 slot: Slot,
26 buffer: EditBuffer,
27 attachments: Option<Attachments>,
28}
29
30impl EditInput {
31 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 pub fn attachments(mut self, attachments: Attachments) -> Self {
45 self.attachments = Some(attachments);
46 self
47 }
48
49 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 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 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 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 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 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 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
325const PREVIEW_COLS: u16 = 12;
327const PREVIEW_ROWS: u16 = 4;
328const PREVIEW_GAP: u16 = 2;
330const PREVIEW_BOX_COLS: u16 = PREVIEW_COLS + 2;
332const PREVIEW_BOX_ROWS: u16 = PREVIEW_ROWS + 2;
333const 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
343pub const fn attachment_color(marker: usize) -> Color {
348 ATTACHMENT_COLORS[marker.saturating_sub(1) % ATTACHMENT_COLORS.len()]
349}
350
351pub 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
365fn 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
376fn collapses_to_chip(text: &str) -> bool {
378 text.len() > 1000 || text.bytes().filter(|byte| *byte == b'\n').count() >= 10
379}
380
381fn 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#[derive(Clone)]
436pub struct Attachment {
437 pub content: AttachmentContent,
439 pub marker: usize,
441 pub color: Color,
443}
444
445#[derive(Clone)]
447pub enum AttachmentContent {
448 Image {
450 source: Str,
452 dimensions: Option<(u32, u32)>,
454 },
455 Text {
457 snippet: Str,
459 lines: usize,
461 chars: usize,
463 },
464}
465
466#[derive(Clone, Default)]
483pub struct Attachments {
484 state: Rc<RefCell<AttachmentState>>,
485}
486
487#[derive(Default)]
488struct AttachmentState {
489 staged: Vec<Staged>,
490 counter: usize,
492 version: u64,
493}
494
495struct Staged {
496 attachment: Attachment,
497 hidden: bool,
498}
499
500impl Attachments {
501 pub fn new() -> Self {
503 Self::default()
504 }
505
506 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 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 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 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 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 pub fn is_empty(&self) -> bool {
589 self.len() == 0
590 }
591}
592
593fn 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
600pub struct EditorPane {
603 props: Props,
604 slot: Slot,
605 children: SmallVec<Cached, 2>,
608 has_status: bool,
609 attachments: Attachments,
610 synced: u64,
612 band: Rect,
614}
615
616impl EditorPane {
617 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 pub fn input(mut self, input: impl IntoComponent) -> Self {
635 self.children[0] = Cached::new(input.into_component());
636 self
637 }
638
639 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 pub fn attachments(&self) -> Attachments {
653 self.attachments.clone()
654 }
655
656 fn preview_start(&self) -> usize {
659 1 + usize::from(self.has_status)
660 }
661
662 fn band_rows(&self) -> u16 {
665 if self.attachments.is_empty() {
666 0
667 } else {
668 PREVIEW_BOX_ROWS + 1
669 }
670 }
671
672 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 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 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 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 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
955fn 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 assert!(attachments.set_visible(|_| true));
1450 assert_eq!(attachments.len(), 3);
1451
1452 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 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 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 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 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 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}