返回 CodeWhale
native_composer.rs
根目录 / vendor / codewhale-ratatui / src / components / native_composer.rs
1 //! Pure mounted composer projection over host-owned editor and menu facts.
2 //! Source positions count Unicode scalar values in the original editor text.
3 //! Display sanitation never mutates that text or its source-position authority.
4
5 //! Adapted from Codewhale, licensed under the MIT License:
6 //! Copyright (c) 2024-2025 DeepSeek-TUI Contributors
7 //!
8 //! Permission is hereby granted, free of charge, to any person obtaining a
9 //! copy of this software and associated documentation files (the "Software"),
10 //! to deal in the Software without restriction, including without limitation
11 //! the rights to use, copy, modify, merge, publish, distribute, sublicense,
12 //! and/or sell copies of the Software, and to permit persons to whom the
13 //! Software is furnished to do so, subject to the following conditions:
14 //!
15 //! The above copyright notice and this permission notice shall be included
16 //! in all copies or substantial portions of the Software.
17 //!
18 //! THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
19 //! IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
20 //! FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
21 //! THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
22 //! LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
23 //! FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
24 //! DEALINGS IN THE SOFTWARE.
25
26 use crate::{NativeComposerDensity, NativeComposerGeometry, Paint, Theme, text};
27 use ratatui::{
28 buffer::Buffer,
29 layout::{Position, Rect},
30 style::Style,
31 text::{Line, Span},
32 widgets::{Block, BorderType, Borders, Paragraph, Widget, Wrap},
33 };
34 use std::borrow::Cow;
35 use unicode_segmentation::UnicodeSegmentation;
36 use unicode_width::UnicodeWidthStr;
37
38 #[derive(Clone, Debug)]
39 pub enum NativeComposerMenuItem {
40 Line(Line<'static>),
41 Columns {
42 name: String,
43 description: String,
44 prefix: Span<'static>,
45 marker: Span<'static>,
46 name_style: Style,
47 description_style: Style,
48 },
49 }
50 #[derive(Clone, Debug, Default)]
51 pub struct NativeComposerMenu {
52 pub items: Vec<NativeComposerMenuItem>,
53 pub selected: usize,
54 pub reserved_rows: usize,
55 pub pointer_rows: bool,
56 }
57 #[derive(Clone, Copy, Debug, Default)]
58 pub struct NativeComposerStyles {
59 pub background: Style,
60 pub border: Style,
61 pub quiet_border: Style,
62 pub text: Style,
63 pub selection: Style,
64 pub prompt: Style,
65 pub submit: Style,
66 }
67
68 /// Complete render facts. Editing, bindings, menu filtering, history and IME
69 /// remain host-owned; the frame projects their one current snapshot.
70 #[derive(Clone, Debug)]
71 pub struct NativeComposerFrame<'a> {
72 pub text: Cow<'a, str>,
73 pub cursor: usize,
74 pub selection: Option<(usize, usize)>,
75 pub placeholder: Line<'static>,
76 pub enclosed: bool,
77 pub density: NativeComposerDensity,
78 pub history_search: bool,
79 pub focused: bool,
80 pub can_submit: bool,
81 pub ascii: bool,
82 pub top_title: Option<Line<'static>>,
83 pub top_right: Option<Line<'static>>,
84 pub hint: Option<Line<'static>>,
85 pub quiet_hint: Option<Line<'static>>,
86 pub styles: NativeComposerStyles,
87 pub menu: NativeComposerMenu,
88 }
89 #[derive(Clone, Debug)]
90 pub struct NativeComposerPlan {
91 pub area: Rect,
92 pub geometry: NativeComposerGeometry,
93 pub cursor: Option<Position>,
94 pub scroll_offset: usize,
95 pub top_padding: usize,
96 pub input_rows_budget: usize,
97 pub source_rows: Vec<(usize, String)>,
98 pub menu_rects: Vec<(usize, Rect)>,
99 pub truncated: Vec<(Rect, String)>,
100 prompt_position: Option<Position>,
101 lines: Vec<Line<'static>>,
102 frame: NativeComposerFrame<'static>,
103 }
104
105 fn safe_span(span: &Span<'static>) -> Span<'static> {
106 Span::styled(text::display_safe(&span.content).into_owned(), span.style)
107 }
108 fn safe_line(line: &Line<'static>) -> Line<'static> {
109 let mut line = line.clone();
110 line.spans = line.spans.iter().map(safe_span).collect();
111 line
112 }
113 pub fn native_composer_input_budget(height: u16, menu: usize) -> usize {
114 usize::from(height).saturating_sub(menu).max(1)
115 }
116 pub fn native_composer_top_padding(rows: usize, budget: usize) -> usize {
117 budget.saturating_sub(rows.max(1).min(budget.max(1))) / 2
118 }
119 pub fn native_composer_content_geometry(inner: Rect, history_search: bool) -> (Rect, u16) {
120 let inset = if !history_search && inner.width >= 3 {
121 2
122 } else {
123 0
124 };
125 (
126 Rect::new(
127 inner.x.saturating_add(inset),
128 inner.y,
129 inner.width.saturating_sub(inset),
130 inner.height,
131 ),
132 inset,
133 )
134 }
135 pub fn native_composer_geometry(
136 area: Rect,
137 enclosed: bool,
138 history_search: bool,
139 ) -> NativeComposerGeometry {
140 let panel = enclosed && area.width >= NATIVE_COMPOSER_PANEL_MIN_WIDTH && area.height >= 3;
141 let (inner, submit) = if panel {
142 let submit = Rect::new(
143 area.right().saturating_sub(5),
144 area.bottom().saturating_sub(2),
145 3,
146 1,
147 );
148 let x = area.x.saturating_add(1);
149 (
150 Rect::new(
151 x,
152 area.y.saturating_add(1),
153 submit.x.saturating_sub(1).saturating_sub(x),
154 area.height.saturating_sub(2),
155 ),
156 Some(submit),
157 )
158 } else if area.height >= 2 {
159 (
160 Rect::new(
161 area.x,
162 area.y.saturating_add(1),
163 area.width,
164 area.height - 1,
165 ),
166 None,
167 )
168 } else {
169 (area, None)
170 };
171 let (text, inset) = native_composer_content_geometry(inner, history_search);
172 NativeComposerGeometry {
173 inner,
174 text,
175 submit,
176 prompt_x: (inset > 0).then_some(inner.x),
177 }
178 }
179 pub fn native_composer_height(
180 rows: usize,
181 menu: usize,
182 available: u16,
183 density: NativeComposerDensity,
184 panel: bool,
185 ) -> u16 {
186 let available = available.max(1);
187 let floor = match density {
188 NativeComposerDensity::Compact => 1,
189 NativeComposerDensity::Comfortable => 2,
190 NativeComposerDensity::Spacious => 3,
191 };
192 let border = if panel && available >= 3 {
193 2
194 } else {
195 usize::from(available >= 2)
196 };
197 rows.max(floor)
198 .saturating_add(menu)
199 .saturating_add(border)
200 .clamp(1, usize::from(available.min(density.max_rows()))) as u16
201 }
202
203 /// Display width after the kit's control/bidi guard; raw source scalars remain
204 /// in each row for caret/selection/pointer projection.
205 pub fn native_composer_grapheme_width(value: &str) -> usize {
206 text::display_safe(value).width()
207 }
208 fn source_width(value: &str) -> usize {
209 text::display_safe(value).width()
210 }
211
212 /// Lossless soft wrapping: row concatenation reproduces each logical source
213 /// line, including hidden source scalars; the painter sanitizes display only.
214 pub fn native_composer_wrap_text(value: &str, width: usize) -> Vec<String> {
215 if width == 0 {
216 return vec![value.to_owned()];
217 }
218 if value.is_empty() {
219 return vec![String::new()];
220 }
221 let mut lines = Vec::new();
222 let mut current = String::new();
223 let mut cells = 0;
224 let mut break_at = None;
225 macro_rules! flush {
226 () => {{
227 match break_at.take() {
228 Some((byte, _)) if byte < current.len() => {
229 let remainder = current.split_off(byte);
230 lines.push(std::mem::replace(&mut current, remainder));
231 cells = source_width(&current)
232 }
233 _ => {
234 lines.push(std::mem::take(&mut current));
235 cells = 0
236 }
237 }
238 }};
239 }
240 for g in value.graphemes(true) {
241 if g == "\n" {
242 break_at = None;
243 lines.push(std::mem::take(&mut current));
244 cells = 0;
245 continue;
246 }
247 let count = native_composer_grapheme_width(g);
248 if cells + count > width && cells != 0 {
249 flush!()
250 }
251 current.push_str(g);
252 cells += count;
253 if g == " " && !current.trim_start().is_empty() {
254 break_at = Some((current.len(), cells))
255 }
256 if cells >= width {
257 flush!()
258 }
259 }
260 lines.push(current);
261 lines
262 }
263 pub fn native_composer_source_rows(value: &str, width: usize) -> Vec<(usize, String)> {
264 if value.is_empty() || width == 0 {
265 return vec![(0, String::new())];
266 }
267 let mut result = Vec::new();
268 let mut start = 0;
269 for raw in value.split('\n') {
270 for row in native_composer_wrap_text(raw, width) {
271 let chars = row.chars().count();
272 result.push((start, row));
273 start += chars
274 }
275 start += 1
276 }
277 result
278 }
279 pub fn native_composer_source_cursor(rows: &[(usize, String)], cursor: usize) -> (usize, usize) {
280 let row = rows
281 .iter()
282 .rposition(|(start, _)| *start <= cursor)
283 .unwrap_or(0);
284 let Some((start, line)) = rows.get(row) else {
285 return (0, 0);
286 };
287 let end = line
288 .char_indices()
289 .nth(cursor.saturating_sub(*start))
290 .map(|(at, _)| at)
291 .unwrap_or(line.len());
292 (row, source_width(&line[..end]))
293 }
294 #[derive(Clone, Debug)]
295 pub struct NativeComposerSourcePlan {
296 pub rows: Vec<(usize, String)>,
297 pub visible: Vec<(usize, String)>,
298 pub cursor_row: usize,
299 pub cursor_col: usize,
300 pub scroll_offset: usize,
301 }
302 pub fn native_composer_source_plan(
303 input: &str,
304 cursor: usize,
305 width: usize,
306 budget: usize,
307 ) -> NativeComposerSourcePlan {
308 let rows = native_composer_source_rows(input, width.max(1));
309 let (row, col) = native_composer_source_cursor(&rows, cursor);
310 let budget = budget.max(1);
311 let mut top = (row + 1).saturating_sub(budget);
312 if top + budget > rows.len() {
313 top = rows.len().saturating_sub(budget)
314 }
315 NativeComposerSourcePlan {
316 visible: rows.iter().skip(top).take(budget).cloned().collect(),
317 cursor_row: row.saturating_sub(top),
318 cursor_col: col.min(width.saturating_sub(1)),
319 scroll_offset: top,
320 rows,
321 }
322 }
323 pub fn native_composer_source_at(
324 input: &str,
325 width: usize,
326 column: usize,
327 row: usize,
328 scroll: usize,
329 padding: usize,
330 ) -> usize {
331 let rows = native_composer_source_rows(input, width.max(1));
332 let absolute = row.saturating_sub(padding).saturating_add(scroll);
333 let Some((start, line)) = rows.get(absolute) else {
334 return input.chars().count();
335 };
336 let mut chars = 0;
337 let mut cells = 0;
338 for g in line.graphemes(true) {
339 let n = native_composer_grapheme_width(g);
340 if cells + n > column {
341 break;
342 }
343 cells += n;
344 chars += g.chars().count()
345 }
346 start + chars
347 }
348 fn selected_line(
349 value: &str,
350 start: usize,
351 selection: Option<(usize, usize)>,
352 styles: NativeComposerStyles,
353 ) -> Line<'static> {
354 let Some((mut a, mut b)) = selection else {
355 return Line::styled(text::display_safe(value).into_owned(), styles.text);
356 };
357 if a > b {
358 std::mem::swap(&mut a, &mut b);
359 }
360 let offsets: Vec<_> = value
361 .char_indices()
362 .map(|(at, _)| at)
363 .chain(std::iter::once(value.len()))
364 .collect();
365 let len = value.chars().count();
366 let a = a.saturating_sub(start).min(len);
367 let b = b.saturating_sub(start).min(len);
368 let mut spans = Vec::new();
369 for (from, to, style) in [
370 (0, a, styles.text),
371 (a, b, styles.selection),
372 (b, len, styles.text),
373 ] {
374 if to > from {
375 spans.push(Span::styled(
376 text::display_safe(&value[offsets[from]..offsets[to]]).into_owned(),
377 style,
378 ))
379 }
380 }
381 Line::from(spans)
382 }
383 fn truncate_columns(value: &str, width: usize) -> String {
384 let safe = text::display_safe(value);
385 if safe.width() <= width {
386 return safe.into_owned();
387 }
388 let budget = if width > 3 { width - 3 } else { width };
389 let mut result = String::new();
390 let mut used = 0;
391 for g in safe.graphemes(true) {
392 let n = g.width();
393 if used + n > budget {
394 break;
395 }
396 result.push_str(g);
397 used += n;
398 }
399 if width > 3 {
400 result.push_str("...");
401 }
402 result
403 }
404 // The prompt inset is raw chrome, independent of placeholder/input ink.
405 // Fold line style into its original spans before adding unstyled padding.
406 fn prompt_inset_line(mut line: Line<'static>, inset: u16) -> Line<'static> {
407 if inset > 0 {
408 for span in &mut line.spans {
409 span.style = line.style.patch(span.style);
410 }
411 line.style = Style::default();
412 line.spans.insert(0, Span::raw(" "));
413 }
414 line
415 }
416
417 impl NativeComposerFrame<'_> {
418 pub fn desired_height(&self, width: u16, available: u16) -> u16 {
419 let panel = native_composer_geometry(
420 Rect::new(0, 0, width, available),
421 self.enclosed,
422 self.history_search,
423 )
424 .submit
425 .is_some();
426 let geometry = native_composer_geometry(
427 Rect::new(0, 0, width, if panel { 3 } else { 1 }),
428 self.enclosed,
429 self.history_search,
430 );
431 native_composer_height(
432 native_composer_source_rows(&self.text, usize::from(geometry.text.width.max(1))).len(),
433 self.menu.reserved_rows,
434 available,
435 self.density,
436 panel,
437 )
438 }
439 pub fn plan(&self, area: Rect) -> NativeComposerPlan {
440 let geometry = native_composer_geometry(area, self.enclosed, self.history_search);
441 let budget = native_composer_input_budget(
442 geometry.inner.height,
443 self.menu.reserved_rows.max(self.menu.items.len()),
444 );
445 let source = native_composer_source_plan(
446 &self.text,
447 self.cursor,
448 usize::from(geometry.text.width.max(1)),
449 budget,
450 );
451 let visual = if self.text.is_empty() {
452 1
453 } else {
454 source.visible.len()
455 };
456 let padding = native_composer_top_padding(visual, budget);
457 let mut lines = vec![Line::from(""); padding];
458 let inset = geometry.text.x.saturating_sub(geometry.inner.x);
459 if self.text.is_empty() {
460 lines.push(prompt_inset_line(safe_line(&self.placeholder), inset))
461 } else {
462 for (start, value) in &source.visible {
463 lines.push(prompt_inset_line(
464 selected_line(value, *start, self.selection, self.styles),
465 inset,
466 ))
467 }
468 }
469 let mut menu_rects = Vec::new();
470 let mut truncated = Vec::new();
471 let slots = usize::from(geometry.inner.height)
472 .saturating_sub(visual)
473 .saturating_sub(padding)
474 .saturating_sub(1)
475 .max(1);
476 let total = self.menu.items.len();
477 let selected = self.menu.selected.min(total.saturating_sub(1));
478 let half = slots / 2;
479 let top = if total <= slots || selected <= half {
480 0
481 } else if selected + half >= total {
482 total.saturating_sub(slots)
483 } else {
484 selected.saturating_sub(half)
485 };
486 let bottom = top.saturating_add(slots).min(total);
487 let width = usize::from(geometry.inner.width.max(1));
488 let label_width = self
489 .menu
490 .items
491 .iter()
492 .take(bottom)
493 .skip(top)
494 .filter_map(|item| match item {
495 NativeComposerMenuItem::Columns { name, .. } => {
496 Some(text::display_safe(name).width())
497 }
498 _ => None,
499 })
500 .max()
501 .unwrap_or(22)
502 .min(width.saturating_sub(4))
503 .max(8);
504 for (index, item) in self.menu.items.iter().enumerate().take(bottom).skip(top) {
505 let y = geometry
506 .inner
507 .y
508 .saturating_add(composer_wrapped_rows(&lines, geometry.inner.width));
509 let mut rect = Rect::new(geometry.inner.x, y, geometry.inner.width, 0);
510 let mut full_text = None;
511 let line = match item {
512 NativeComposerMenuItem::Line(line) => safe_line(line),
513 NativeComposerMenuItem::Columns {
514 name,
515 description,
516 prefix,
517 marker,
518 name_style,
519 description_style,
520 } => {
521 let name = text::display_safe(name);
522 let description = text::display_safe(description);
523 let prefix = safe_span(prefix);
524 let marker = safe_span(marker);
525 let mut label = truncate_columns(&name, label_width);
526 while label.width() < label_width {
527 label.push(' ')
528 }
529 let capacity = width.saturating_sub(
530 1 + marker.content.width() + prefix.content.width() + label_width + 2,
531 );
532 let desc = truncate_columns(&description, capacity);
533 if name.width() > label_width || description.width() > capacity {
534 full_text = Some(if description.trim().is_empty() {
535 name.into_owned()
536 } else {
537 format!("{name} {description}")
538 });
539 }
540 Line::from(vec![
541 Span::raw(" "),
542 marker,
543 prefix,
544 Span::styled(label, *name_style),
545 Span::styled(" ", *description_style),
546 Span::styled(desc, *description_style),
547 ])
548 }
549 };
550 rect.height = composer_wrapped_rows(std::slice::from_ref(&line), geometry.inner.width)
551 .min(geometry.inner.bottom().saturating_sub(y));
552 if !rect.is_empty() {
553 if self.menu.pointer_rows {
554 menu_rects.push((index, rect));
555 }
556 if let Some(full) = full_text {
557 truncated.push((rect, full));
558 }
559 }
560 lines.push(line);
561 }
562 let x = geometry
563 .text
564 .x
565 .saturating_add(u16::try_from(source.cursor_col).unwrap_or(u16::MAX));
566 let y = geometry
567 .text
568 .y
569 .saturating_add(u16::try_from(source.cursor_row + padding).unwrap_or(u16::MAX));
570 let prompt_position = geometry
571 .text
572 .contains(Position::new(x, y))
573 .then_some(Position::new(x, y));
574 let cursor = if self.focused { prompt_position } else { None };
575 let frame = NativeComposerFrame {
576 text: Cow::Owned(self.text.to_string()),
577 cursor: self.cursor,
578 selection: self.selection,
579 placeholder: self.placeholder.clone(),
580 enclosed: self.enclosed,
581 density: self.density,
582 history_search: self.history_search,
583 focused: self.focused,
584 can_submit: self.can_submit,
585 ascii: self.ascii,
586 top_title: self.top_title.clone(),
587 top_right: self.top_right.clone(),
588 hint: self.hint.clone(),
589 quiet_hint: self.quiet_hint.clone(),
590 styles: self.styles,
591 menu: self.menu.clone(),
592 };
593 NativeComposerPlan {
594 area,
595 geometry,
596 cursor,
597 scroll_offset: source.scroll_offset,
598 top_padding: padding,
599 input_rows_budget: budget,
600 source_rows: source.rows,
601 menu_rects,
602 truncated,
603 prompt_position,
604 lines,
605 frame,
606 }
607 }
608 pub fn render(&self, area: Rect, buf: &mut Buffer) -> NativeComposerPlan {
609 let plan = self.plan(area.intersection(buf.area));
610 plan.paint(buf);
611 plan
612 }
613 }
614 impl NativeComposerPlan {
615 fn paint(&self, buf: &mut Buffer) {
616 if self.area.is_empty() {
617 return;
618 }
619 let f = &self.frame;
620 let area = self.area;
621 let panel = self.geometry.submit.is_some();
622 let mut block = Block::default().style(f.styles.background);
623 if panel {
624 block = block
625 .borders(Borders::ALL)
626 .border_type(BorderType::Rounded)
627 .border_style(f.styles.border)
628 } else if area.height >= 2 {
629 block = block
630 .borders(Borders::TOP)
631 .border_style(f.styles.quiet_border)
632 }
633 if f.ascii {
634 block = block.border_set(ASCII_BORDER)
635 }
636 block.render(area, buf);
637 if area.height >= 2 {
638 let mut top = Block::default()
639 .borders(Borders::TOP)
640 .border_style(if panel {
641 f.styles.border
642 } else {
643 f.styles.quiet_border
644 })
645 .style(f.styles.background);
646 if f.ascii {
647 top = top.border_set(ASCII_BORDER);
648 }
649 if panel && let Some(title) = &f.top_title {
650 top = top.title(safe_line(title))
651 }
652 if let Some(right) = &f.top_right {
653 top = top.title_top(safe_line(right).right_aligned())
654 }
655 if !panel && let Some(hint) = &f.quiet_hint {
656 top = top.title(safe_line(hint))
657 }
658 top.render(area, buf);
659 if panel {
660 let mut bottom = Block::default()
661 .borders(Borders::BOTTOM)
662 .border_style(f.styles.border)
663 .style(f.styles.background);
664 if f.ascii {
665 bottom = bottom.border_set(ASCII_BORDER);
666 }
667 if let Some(hint) = &f.hint {
668 bottom = bottom.title_bottom(safe_line(hint))
669 }
670 bottom.render(area, buf);
671 for (x, y, mark) in [
672 (area.x, area.y, "╭"),
673 (area.right() - 1, area.y, "╮"),
674 (area.x, area.bottom() - 1, "╰"),
675 (area.right() - 1, area.bottom() - 1, "╯"),
676 ] {
677 buf[(x, y)]
678 .set_symbol(if f.ascii { "+" } else { mark })
679 .set_style(f.styles.border.patch(f.styles.background));
680 }
681 }
682 }
683 Paragraph::new(self.lines.clone())
684 .style(f.styles.background)
685 .wrap(Wrap { trim: false })
686 .render(self.geometry.inner, buf);
687 if let (Some(x), Some(cursor)) = (self.geometry.prompt_x, self.prompt_position) {
688 buf[(x, cursor.y)]
689 .set_symbol(if f.ascii { ">" } else { "❯" })
690 .set_style(f.styles.prompt);
691 }
692 if let Some(rect) = self.geometry.submit {
693 let mark = match (f.can_submit, f.ascii) {
694 (true, false) => "[↵]",
695 (true, true) => "[>]",
696 (false, false) => "[·]",
697 (false, true) => "[.]",
698 };
699 buf.set_stringn(rect.x, rect.y, mark, 3, f.styles.submit);
700 }
701 }
702 }
703 impl Paint for NativeComposerFrame<'_> {
704 fn paint(&self, area: Rect, buf: &mut Buffer, _theme: &Theme) {
705 self.render(area, buf);
706 }
707 fn height(&self, width: u16, _theme: &Theme) -> u16 {
708 self.desired_height(width, self.density.max_rows())
709 }
710 }
711
712 const ASCII_BORDER: ratatui::symbols::border::Set<'static> = ratatui::symbols::border::Set {
713 top_left: "+",
714 top_right: "+",
715 bottom_left: "+",
716 bottom_right: "+",
717 vertical_left: "|",
718 vertical_right: "|",
719 horizontal_top: "-",
720 horizontal_bottom: "-",
721 };
722
723 pub const NATIVE_COMPOSER_PANEL_MIN_WIDTH: u16 = 12;
724 fn composer_wrapped_rows(lines: &[Line<'static>], width: u16) -> u16 {
725 if width == 0 {
726 return 0;
727 }
728 u16::try_from(
729 Paragraph::new(lines.to_vec())
730 .wrap(Wrap { trim: false })
731 .line_count(width),
732 )
733 .unwrap_or(u16::MAX)
734 }
735 #[derive(Clone, Copy, Debug)]
736 pub enum NativeComposerRowColumn {
737 DisplayCells,
738 SourceScalars,
739 }
740 /// Pure projection for an existing editor's visual-row command. The host
741 /// decides whether keys/wheel claim that command and applies the result.
742 pub fn native_composer_step_row(
743 input: &str,
744 cursor: usize,
745 width: usize,
746 delta: isize,
747 column: NativeComposerRowColumn,
748 ) -> Option<usize> {
749 if input.is_empty() || delta == 0 {
750 return None;
751 }
752 let rows = native_composer_source_rows(input, width.max(1));
753 if rows.len() < 2 {
754 return None;
755 }
756 let cursor = cursor.min(input.chars().count());
757 let (row, cells) = native_composer_source_cursor(&rows, cursor);
758 let target = if delta < 0 {
759 row.saturating_sub(delta.unsigned_abs())
760 } else {
761 row.saturating_add(delta as usize).min(rows.len() - 1)
762 };
763 if row == target {
764 return None;
765 }
766 let (start, text) = &rows[target];
767 let offset = match column {
768 NativeComposerRowColumn::SourceScalars => {
769 cursor.saturating_sub(rows[row].0).min(text.chars().count())
770 }
771 NativeComposerRowColumn::DisplayCells => {
772 let mut count = 0;
773 let mut used = 0;
774 for g in text.graphemes(true) {
775 let width = native_composer_grapheme_width(g);
776 if used + width > cells {
777 break;
778 }
779 used += width;
780 count += g.chars().count();
781 }
782 count
783 }
784 };
785 Some(start.saturating_add(offset).min(input.chars().count()))
786 }
787
787 lines RUST