work on ascii driver
authorBen Pfaff <blp@cs.stanford.edu>
Thu, 30 Jan 2025 21:38:53 +0000 (13:38 -0800)
committerBen Pfaff <blp@cs.stanford.edu>
Thu, 30 Jan 2025 21:38:53 +0000 (13:38 -0800)
rust/pspp/src/output/csv.rs
rust/pspp/src/output/mod.rs
rust/pspp/src/output/render.rs
rust/pspp/src/output/text.rs [new file with mode: 0644]
rust/pspp/src/output/text_line.rs [new file with mode: 0644]

index 73149f8dd0036db2472f0d396f8085faa60b45f6..4dd38f2a15c5c8e428aeed04bc1e5ae46f7fc8ae 100644 (file)
@@ -8,12 +8,7 @@ use std::{
 
 use crate::output::pivot::Coord2;
 
-use super::{
-    driver::Driver,
-    pivot::{PivotTable, ValueOptions},
-    table::Table,
-    Details, Item, TextType,
-};
+use super::{driver::Driver, pivot::PivotTable, table::Table, Details, Item, TextType};
 
 struct CsvDriver {
     file: File,
@@ -166,12 +161,7 @@ impl Driver for CsvDriver {
                 TextType::Syntax | TextType::PageTitle => (),
                 TextType::Title | TextType::Log => {
                     self.start_item();
-                    for line in text
-                        .content
-                        .display(ValueOptions::default())
-                        .to_string()
-                        .lines()
-                    {
+                    for line in text.content.display(()).to_string().lines() {
                         writeln!(&self.file, "{}", CsvField::new(line, self.options)).unwrap();
                     }
                 }
index edf93bfd82055cfe719b072365fa62937f86170b..6feac2adcbed784097b103db1af5c62c8e137167 100644 (file)
@@ -13,6 +13,8 @@ pub mod page;
 pub mod pivot;
 pub mod render;
 pub mod table;
+pub mod text;
+pub mod text_line;
 
 /// A single output item.
 pub struct Item {
index 6f78a28136241eeede29862a2bbec7abdf78038e..b4937f707ed0c1980ed5639d0dd6332a0f108a1e 100644 (file)
@@ -31,44 +31,44 @@ use super::table::{CellInner, Content, Table};
 pub struct Params {
     /// Page size to try to fit the rendering into.  Some tables will, of
     /// course, overflow this size.
-    size: Coord2,
+    pub size: Coord2,
 
     /// Nominal size of a character in the most common font:
     /// `font_size[Axis2::X]` is the em width.
     /// `font_size[Axis2::Y]` is the line leading.
-    font_size: EnumMap<Axis2, usize>,
+    pub font_size: EnumMap<Axis2, usize>,
 
     /// Width of different kinds of lines.
-    line_widths: EnumMap<Stroke, usize>,
+    pub line_widths: EnumMap<Stroke, usize>,
 
     /// 1/96" of an inch (1px) in the rendering unit.  Currently used only for
     /// column width ranges, as in `width_ranges` in
     /// [crate::output::pivot::Look].  Set to `None` to disable this feature.
-    px_size: Option<usize>,
+    pub px_size: Option<usize>,
 
     /// Minimum cell width or height before allowing the cell to be broken
     /// across two pages.  (Joined cells may always be broken at join
     /// points.)
-    min_break: EnumMap<Axis2, usize>,
+    pub min_break: EnumMap<Axis2, usize>,
 
     /// True if the driver supports cell margins.  (If false, the rendering
     /// engine will insert a small space betweeen adjacent cells that don't have
     /// an intervening rule.)
-    supports_margins: bool,
+    pub supports_margins: bool,
 
     /// True if the local language has a right-to-left direction, otherwise
     /// false.
-    rtl: bool,
+    pub rtl: bool,
 
     /// True if the table is being rendered for printing (as opposed to
     /// on-screen display).
-    printing: bool,
+    pub printing: bool,
 
     /// Whether [RenderOps::adjust_break] is implemented.
-    can_adjust_break: bool,
+    pub can_adjust_break: bool,
 
     /// Whether [RenderOps::scale] is implemented.
-    can_scale: bool,
+    pub can_scale: bool,
 }
 
 pub trait Device {
@@ -97,7 +97,7 @@ pub trait Device {
     fn adjust_break(&self, cell: &Content, size: Coord2) -> usize;
 }
 
-trait Draw {
+pub trait Draw {
     /// Draws a generalized intersection of lines in `bb`.
     ///
     /// `styles` is interpreted this way:
@@ -138,7 +138,7 @@ trait Draw {
     fn scale(&mut self, factor: f64);
 }
 
-struct DrawCell<'a> {
+pub struct DrawCell<'a> {
     pub rotate: bool,
     pub inner: &'a ValueInner,
     pub style: &'a AreaStyle,
@@ -1425,7 +1425,7 @@ impl Pager {
     }
 
     /// True if there's content left to rnder.
-    fn has_next(&mut self) -> bool {
+    pub fn has_next(&mut self) -> bool {
         while self
             .y_break
             .as_mut()
@@ -1455,7 +1455,7 @@ impl Pager {
     /// Returns the amount of vertical space actually used by the rendered
     /// chunk, which will be 0 if `space` is too small to render anything or if
     /// no content remains (use [Self::has_next] to distinguish these cases).
-    fn draw_next(&mut self, mut space: usize, draw: &mut dyn Draw) -> usize {
+    pub fn draw_next(&mut self, mut space: usize, draw: &mut dyn Draw) -> usize {
         use Axis2::*;
 
         if self.scale != 1.0 {
diff --git a/rust/pspp/src/output/text.rs b/rust/pspp/src/output/text.rs
new file mode 100644 (file)
index 0000000..256dc8b
--- /dev/null
@@ -0,0 +1,324 @@
+use std::{
+    borrow::Cow,
+    fs::File,
+    io::{BufWriter, Write},
+    sync::{Arc, LazyLock},
+};
+
+use enum_map::{Enum, EnumMap};
+
+use super::{
+    driver::Driver,
+    pivot::{Axis2, BorderStyle, Coord2, PivotTable, Rect2, Stroke},
+    render::{Device, Draw, DrawCell, Pager, Params},
+    table::{CellInner, Content},
+    text_line::TextLine,
+    Details, Item,
+};
+
+pub struct TextDriver {
+    file: BufWriter<File>,
+
+    /// Enable bold and underline in output?
+    emphasis: bool,
+
+    /// Page width.
+    width: usize,
+
+    /// Minimum cell size to break across pages.
+    min_hbreak: usize,
+
+    box_chars: &'static BoxChars,
+
+    params: Params,
+    n_objects: usize,
+    lines: Vec<TextLine>,
+}
+
+#[derive(Copy, Clone, PartialEq, Eq, Enum)]
+enum Line {
+    None,
+    Dashed,
+    Single,
+    Double,
+}
+
+impl From<Stroke> for Line {
+    fn from(stroke: Stroke) -> Self {
+        match stroke {
+            Stroke::None => Self::None,
+            Stroke::Solid | Stroke::Thick | Stroke::Thin => Self::Single,
+            Stroke::Dashed => Self::Dashed,
+            Stroke::Double => Self::Double,
+        }
+    }
+}
+
+#[derive(Copy, Clone, PartialEq, Eq, Enum)]
+struct Lines {
+    r: Line,
+    b: Line,
+    l: Line,
+    t: Line,
+}
+
+#[derive(Default)]
+struct BoxChars(EnumMap<Lines, char>);
+
+impl BoxChars {
+    fn put(&mut self, r: Line, b: Line, l: Line, chars: [char; 4]) {
+        use Line::*;
+        for (t, c) in [None, Dashed, Single, Double]
+            .into_iter()
+            .zip(chars.into_iter())
+        {
+            self.0[Lines { r, b, l, t }] = c;
+        }
+    }
+    fn get(&self, lines: Lines) -> char {
+        self.0[lines]
+    }
+}
+
+static ASCII_BOX: LazyLock<BoxChars> = LazyLock::new(|| {
+    let mut ascii_box = BoxChars::default();
+    let n = Line::None;
+    let d = Line::Dashed;
+    use Line::{Double as D, Single as S};
+    ascii_box.put(n, n, n, [' ', '|', '|', '#']);
+    ascii_box.put(n, n, d, ['-', '+', '+', '#']);
+    ascii_box.put(n, n, S, ['-', '+', '+', '#']);
+    ascii_box.put(n, n, D, ['=', '#', '#', '#']);
+    ascii_box.put(n, d, n, ['|', '|', '|', '#']);
+    ascii_box.put(n, d, d, ['+', '+', '+', '#']);
+    ascii_box.put(n, d, S, ['+', '+', '+', '#']);
+    ascii_box.put(n, d, D, ['#', '#', '#', '#']);
+    ascii_box.put(n, S, n, ['|', '|', '|', '#']);
+    ascii_box.put(n, S, d, ['+', '+', '+', '#']);
+    ascii_box.put(n, S, S, ['+', '+', '+', '#']);
+    ascii_box.put(n, S, D, ['#', '#', '#', '#']);
+    ascii_box.put(n, D, n, ['#', '#', '#', '#']);
+    ascii_box.put(n, D, d, ['#', '#', '#', '#']);
+    ascii_box.put(n, D, S, ['#', '#', '#', '#']);
+    ascii_box.put(n, D, D, ['#', '#', '#', '#']);
+    ascii_box.put(d, n, n, ['-', '+', '+', '#']);
+    ascii_box.put(d, n, d, ['-', '+', '+', '#']);
+    ascii_box.put(d, n, S, ['-', '+', '+', '#']);
+    ascii_box.put(d, n, D, ['#', '#', '#', '#']);
+    ascii_box.put(d, d, n, ['+', '+', '+', '#']);
+    ascii_box.put(d, d, d, ['+', '+', '+', '#']);
+    ascii_box.put(d, d, S, ['+', '+', '+', '#']);
+    ascii_box.put(d, d, D, ['#', '#', '#', '#']);
+    ascii_box.put(d, S, n, ['+', '+', '+', '#']);
+    ascii_box.put(d, S, d, ['+', '+', '+', '#']);
+    ascii_box.put(d, S, S, ['+', '+', '+', '#']);
+    ascii_box.put(d, S, D, ['#', '#', '#', '#']);
+    ascii_box.put(d, D, n, ['#', '#', '#', '#']);
+    ascii_box.put(d, D, d, ['#', '#', '#', '#']);
+    ascii_box.put(d, D, S, ['#', '#', '#', '#']);
+    ascii_box.put(d, D, D, ['#', '#', '#', '#']);
+    ascii_box.put(S, n, n, ['-', '+', '+', '#']);
+    ascii_box.put(S, n, d, ['-', '+', '+', '#']);
+    ascii_box.put(S, n, S, ['-', '+', '+', '#']);
+    ascii_box.put(S, n, D, ['#', '#', '#', '#']);
+    ascii_box.put(S, d, n, ['+', '+', '+', '#']);
+    ascii_box.put(S, d, d, ['+', '+', '+', '#']);
+    ascii_box.put(S, d, S, ['+', '+', '+', '#']);
+    ascii_box.put(S, d, D, ['#', '#', '#', '#']);
+    ascii_box.put(S, S, n, ['+', '+', '+', '#']);
+    ascii_box.put(S, S, d, ['+', '+', '+', '#']);
+    ascii_box.put(S, S, S, ['+', '+', '+', '#']);
+    ascii_box.put(S, S, D, ['#', '#', '#', '#']);
+    ascii_box.put(S, D, n, ['#', '#', '#', '#']);
+    ascii_box.put(S, D, d, ['#', '#', '#', '#']);
+    ascii_box.put(S, D, S, ['#', '#', '#', '#']);
+    ascii_box.put(S, D, D, ['#', '#', '#', '#']);
+    ascii_box.put(D, n, n, ['=', '#', '#', '#']);
+    ascii_box.put(D, n, d, ['#', '#', '#', '#']);
+    ascii_box.put(D, n, S, ['#', '#', '#', '#']);
+    ascii_box.put(D, n, D, ['=', '#', '#', '#']);
+    ascii_box.put(D, d, n, ['#', '#', '#', '#']);
+    ascii_box.put(D, d, d, ['#', '#', '#', '#']);
+    ascii_box.put(D, d, S, ['#', '#', '#', '#']);
+    ascii_box.put(D, d, D, ['#', '#', '#', '#']);
+    ascii_box.put(D, S, n, ['#', '#', '#', '#']);
+    ascii_box.put(D, S, d, ['#', '#', '#', '#']);
+    ascii_box.put(D, S, S, ['#', '#', '#', '#']);
+    ascii_box.put(D, S, D, ['#', '#', '#', '#']);
+    ascii_box.put(D, D, n, ['#', '#', '#', '#']);
+    ascii_box.put(D, D, d, ['#', '#', '#', '#']);
+    ascii_box.put(D, D, S, ['#', '#', '#', '#']);
+    ascii_box.put(D, D, D, ['#', '#', '#', '#']);
+    ascii_box
+});
+
+static UNICODE_BOX: LazyLock<BoxChars> = LazyLock::new(|| {
+    let mut unicode_box = BoxChars::default();
+    let n = Line::None;
+    let d = Line::Dashed;
+    use Line::{Double as D, Single as S};
+    unicode_box.put(n, n, n, [' ', '╵', '╵', '║']);
+    unicode_box.put(n, n, d, ['╴', '╯', '╯', '╜']);
+    unicode_box.put(n, n, S, ['╴', '╯', '╯', '╜']);
+    unicode_box.put(n, n, D, ['═', '╛', '╛', '╝']);
+    unicode_box.put(n, S, n, ['╷', '│', '│', '║']);
+    unicode_box.put(n, S, d, ['╮', '┤', '┤', '╢']);
+    unicode_box.put(n, S, S, ['╮', '┤', '┤', '╢']);
+    unicode_box.put(n, S, D, ['╕', '╡', '╡', '╣']);
+    unicode_box.put(n, d, n, ['╷', '┊', '│', '║']);
+    unicode_box.put(n, d, d, ['╮', '┤', '┤', '╢']);
+    unicode_box.put(n, d, S, ['╮', '┤', '┤', '╢']);
+    unicode_box.put(n, d, D, ['╕', '╡', '╡', '╣']);
+    unicode_box.put(n, D, n, ['║', '║', '║', '║']);
+    unicode_box.put(n, D, d, ['╖', '╢', '╢', '╢']);
+    unicode_box.put(n, D, S, ['╖', '╢', '╢', '╢']);
+    unicode_box.put(n, D, D, ['╗', '╣', '╣', '╣']);
+    unicode_box.put(d, n, n, ['╶', '╰', '╰', '╙']);
+    unicode_box.put(d, n, d, ['╌', '┴', '┴', '╨']);
+    unicode_box.put(d, n, S, ['─', '┴', '┴', '╨']);
+    unicode_box.put(d, n, D, ['═', '╧', '╧', '╩']);
+    unicode_box.put(d, d, n, ['╭', '├', '├', '╟']);
+    unicode_box.put(d, d, d, ['┬', '+', '┼', '╪']);
+    unicode_box.put(d, d, S, ['┬', '┼', '┼', '╪']);
+    unicode_box.put(d, d, D, ['╤', '╪', '╪', '╬']);
+    unicode_box.put(d, S, n, ['╭', '├', '├', '╟']);
+    unicode_box.put(d, S, d, ['┬', '┼', '┼', '╪']);
+    unicode_box.put(d, S, S, ['┬', '┼', '┼', '╪']);
+    unicode_box.put(d, S, D, ['╤', '╪', '╪', '╬']);
+    unicode_box.put(d, D, n, ['╓', '╟', '╟', '╟']);
+    unicode_box.put(d, D, d, ['╥', '╫', '╫', '╫']);
+    unicode_box.put(d, D, S, ['╥', '╫', '╫', '╫']);
+    unicode_box.put(d, D, D, ['╦', '╬', '╬', '╬']);
+    unicode_box.put(S, n, n, ['╶', '╰', '╰', '╙']);
+    unicode_box.put(S, n, d, ['─', '┴', '┴', '╨']);
+    unicode_box.put(S, n, S, ['─', '┴', '┴', '╨']);
+    unicode_box.put(S, n, D, ['═', '╧', '╧', '╩']);
+    unicode_box.put(S, d, n, ['╭', '├', '├', '╟']);
+    unicode_box.put(S, d, d, ['┬', '┼', '┼', '╪']);
+    unicode_box.put(S, d, S, ['┬', '┼', '┼', '╪']);
+    unicode_box.put(S, d, D, ['╤', '╪', '╪', '╬']);
+    unicode_box.put(S, S, n, ['╭', '├', '├', '╟']);
+    unicode_box.put(S, S, d, ['┬', '┼', '┼', '╪']);
+    unicode_box.put(S, S, S, ['┬', '┼', '┼', '╪']);
+    unicode_box.put(S, S, D, ['╤', '╪', '╪', '╬']);
+    unicode_box.put(S, D, n, ['╓', '╟', '╟', '╟']);
+    unicode_box.put(S, D, d, ['╥', '╫', '╫', '╫']);
+    unicode_box.put(S, D, S, ['╥', '╫', '╫', '╫']);
+    unicode_box.put(S, D, D, ['╦', '╬', '╬', '╬']);
+    unicode_box.put(D, n, n, ['═', '╘', '╘', '╚']);
+    unicode_box.put(D, n, d, ['═', '╧', '╧', '╩']);
+    unicode_box.put(D, n, S, ['═', '╧', '╧', '╩']);
+    unicode_box.put(D, n, D, ['═', '╧', '╧', '╩']);
+    unicode_box.put(D, d, n, ['╒', '╞', '╞', '╠']);
+    unicode_box.put(D, d, d, ['╤', '╪', '╪', '╬']);
+    unicode_box.put(D, d, S, ['╤', '╪', '╪', '╬']);
+    unicode_box.put(D, d, D, ['╤', '╪', '╪', '╬']);
+    unicode_box.put(D, S, n, ['╒', '╞', '╞', '╠']);
+    unicode_box.put(D, S, d, ['╤', '╪', '╪', '╬']);
+    unicode_box.put(D, S, S, ['╤', '╪', '╪', '╬']);
+    unicode_box.put(D, S, D, ['╤', '╪', '╪', '╬']);
+    unicode_box.put(D, D, n, ['╔', '╠', '╠', '╠']);
+    unicode_box.put(D, D, d, ['╠', '╬', '╬', '╬']);
+    unicode_box.put(D, D, S, ['╠', '╬', '╬', '╬']);
+    unicode_box.put(D, D, D, ['╦', '╬', '╬', '╬']);
+    unicode_box
+});
+
+impl TextDriver {
+    fn new(file: File) -> TextDriver {
+        let width = 80;
+        Self {
+            file: BufWriter::new(file),
+            emphasis: false,
+            width,
+            min_hbreak: 20,
+            box_chars: &*ASCII_BOX,
+            n_objects: 0,
+            params: Params {
+                size: Coord2::new(width, usize::MAX),
+                font_size: EnumMap::from_fn(|_| 1),
+                line_widths: EnumMap::from_fn(|stroke| if stroke == Stroke::None { 0 } else { 1 }),
+                px_size: None,
+                min_break: EnumMap::default(),
+                supports_margins: false,
+                rtl: false,
+                printing: true,
+                can_adjust_break: false,
+                can_scale: false,
+            },
+            lines: Vec::new(),
+        }
+    }
+
+    fn output_table(&mut self, table: &PivotTable) {
+        for layer_indexes in table.layers(true) {
+            let pager = Pager::new(todo!(), table, Some(layer_indexes.as_slice()));
+            while pager.has_next() {
+                if self.n_objects > 0 {
+                    writeln!(&mut self.file).unwrap();
+                }
+                self.n_objects += 1;
+
+                pager.draw_next(usize::MAX, self);
+            }
+        }
+    }
+}
+
+impl Driver for TextDriver {
+    fn name(&self) -> Cow<'static, str> {
+        Cow::from("text")
+    }
+
+    fn write(&mut self, item: &Arc<Item>) {
+        match &item.details {
+            Details::Chart => todo!(),
+            Details::Image => todo!(),
+            Details::Group(vec) => todo!(),
+            Details::Message(diagnostic) => todo!(),
+            Details::PageBreak => todo!(),
+            Details::Table(pivot_table) => self.output_table(pivot_table),
+            Details::Text(text) => todo!(),
+        }
+    }
+}
+
+impl Draw for TextDriver {
+    fn draw_line(&mut self, bb: Rect2, styles: EnumMap<Axis2, [BorderStyle; 2]>) {
+        todo!()
+    }
+
+    fn draw_cell(
+        &mut self,
+        draw_cell: &DrawCell,
+        alternate_row: bool,
+        bb: &Rect2,
+        valign_offset: usize,
+        spill: EnumMap<Axis2, [usize; 2]>,
+        clip: &Rect2,
+    ) {
+        todo!()
+    }
+
+    fn scale(&mut self, factor: f64) {
+        unreachable!()
+    }
+}
+
+impl Device for TextDriver {
+    fn params(&self) -> &Params {
+        todo!()
+    }
+
+    fn measure_cell_width(&self, cell: &CellInner) -> [usize; 2] {
+        todo!()
+    }
+
+    fn measure_cell_height(&self, cell: &CellInner, width: usize) -> usize {
+        todo!()
+    }
+
+    fn adjust_break(&self, cell: &Content, size: Coord2) -> usize {
+        todo!()
+    }
+}
diff --git a/rust/pspp/src/output/text_line.rs b/rust/pspp/src/output/text_line.rs
new file mode 100644 (file)
index 0000000..79f1164
--- /dev/null
@@ -0,0 +1,178 @@
+use std::ops::Range;
+
+use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
+
+/// A line of text, encoded in UTF-8, with support functions that properly
+/// handle double-width characters and backspaces.
+///
+/// Designed to make appending text fast, and access and modification of other
+/// column positions possible.
+#[derive(Default)]
+pub struct TextLine {
+    /// Content.
+    string: String,
+
+    /// Display width, in character positions.
+    width: usize,
+}
+
+impl TextLine {
+    pub fn new() -> Self {
+        Self::default()
+    }
+
+    pub fn clear(&mut self) {
+        self.string.clear();
+        self.width = 0;
+    }
+
+    /// Changes the width of this line to `x` columns.  If `x` is longer than
+    /// the current width, extends the line with spaces.  If `x` is shorter than
+    /// the current width, removes trailing characters.
+    pub fn resize(&mut self, x: usize) {
+        if x > self.width {
+            self.string.extend((self.width..x).map(|_| ' '));
+        } else if x < self.width {
+            let pos = self.find_pos(x);
+            self.string.truncate(pos.offsets.start);
+            if x > pos.offsets.start {
+                self.string.extend((pos.offsets.start..x).map(|_| '?'));
+            }
+        }
+        self.width = x;
+    }
+
+    pub fn put(&mut self, x0: usize, s: &str) {
+        let w = s.width();
+        let x1 = x0 + w;
+        if w == 0 {
+            // Nothing to do.
+        } else if x0 >= self.width {
+            // The common case: adding new characters at the end of a line.
+            self.string.extend((self.width..x0).map(|_| ' '));
+            self.string.push_str(s);
+            self.width = x1;
+        } else if x1 >= self.width {
+            let p0 = self.find_pos(x0);
+
+            // If a double-width character occupies both `x0 - 1` and `x0`, then
+            // replace its first character width by `?`.
+            self.string.truncate(p0.offsets.start);
+            self.string.extend((p0.columns.start..x0).map(|_| '?'));
+            self.string.push_str(s);
+            self.width = x1;
+        } else {
+            let span = self.find_span(x0, x1);
+            if span.columns.start < x0 || span.columns.end > x1 {
+                let tail = self.string.split_off(span.offsets.end);
+                self.string.truncate(span.offsets.start);
+                self.string.extend((span.offsets.start..x0).map(|_| '?'));
+                self.string.push_str(s);
+                self.string.extend((x1..span.offsets.end).map(|_| '?'));
+                self.string.push_str(&tail);
+            } else {
+                self.string.replace_range(span.offsets, s);
+            }
+        }
+    }
+
+    fn find_span(&self, x0: usize, x1: usize) -> Position {
+        let p0 = self.find_pos(x0);
+        let p1 = self.find_pos(x1);
+        if p0.columns.start >= x1 {
+            Position {
+                columns: p0.columns.start..p1.columns.start,
+                offsets: p0.offsets.start..p1.offsets.start,
+            }
+        } else {
+            Position {
+                columns: p0.columns.start..p1.columns.end,
+                offsets: p0.offsets.start..p1.offsets.end,
+            }
+        }
+    }
+
+    fn find_pos(&self, target_x: usize) -> Position {
+        let mut x = 0;
+        let mut ofs = 0;
+        let mut widths = Widths::new(&self.string);
+        while let Some(w) = widths.next() {
+            if x + w > target_x {
+                return Position {
+                    columns: x..x + w,
+                    offsets: ofs..widths.offset(),
+                };
+            }
+            ofs = widths.offset();
+            x += w;
+        }
+
+        // This can happen if there are non-printable characters in a line.
+        Position {
+            columns: x..x,
+            offsets: ofs..ofs,
+        }
+    }
+}
+
+/// Position of one or more characters within a [TextLine].
+struct Position {
+    /// 0-based display columns.
+    columns: Range<usize>,
+
+    /// Byte offests.
+    offsets: Range<usize>,
+}
+
+struct Widths<'a> {
+    s: &'a str,
+    base: &'a str,
+}
+
+impl<'a> Widths<'a> {
+    fn new(s: &'a str) -> Self {
+        Self { s, base: s }
+    }
+
+    fn as_str(&self) -> &str {
+        self.s
+    }
+
+    fn offset(&self) -> usize {
+        self.base.len() - self.s.len()
+    }
+}
+
+impl<'a> Iterator for Widths<'a> {
+    type Item = usize;
+
+    fn next(&mut self) -> Option<Self::Item> {
+        let mut iter = self.s.char_indices();
+        let (_, mut c) = iter.next()?;
+        if iter.as_str().starts_with('\x08') {
+            iter.next();
+            c = match iter.next() {
+                Some((_, c)) => c,
+                _ => {
+                    self.s = iter.as_str();
+                    return Some(0);
+                }
+            };
+        }
+
+        let w = c.width().unwrap_or_default();
+        if w == 0 {
+            self.s = iter.as_str();
+            return Some(0);
+        }
+
+        while let Some((index, c)) = iter.next() {
+            if c.width().is_some_and(|width| width > 0) {
+                self.s = &self.s[index..];
+                return Some(w);
+            }
+        }
+        self.s = iter.as_str();
+        Some(w)
+    }
+}