From cc72aa4ddc9a380865bd9dad2f4690c283e8391a Mon Sep 17 00:00:00 2001 From: Alexandre Bury Date: Mon, 4 Jul 2016 16:04:32 -0700 Subject: [PATCH] Better wide char support --- assets/cities.txt | 4 +- src/menubar.rs | 6 +- src/view/button.rs | 3 +- src/view/dialog.rs | 6 +- src/view/edit_view.rs | 157 ++++++++++++++++++++++++++++------------ src/view/select_view.rs | 6 +- src/view/text_view.rs | 12 +-- 7 files changed, 135 insertions(+), 59 deletions(-) diff --git a/assets/cities.txt b/assets/cities.txt index 93f18a4..3b08c85 100644 --- a/assets/cities.txt +++ b/assets/cities.txt @@ -28,6 +28,7 @@ Bangui Banjul Basseterre Beijing +北京 Beirut Belgrade Belmopan @@ -227,6 +228,7 @@ Thimphu Tirana Tiraspol Tokyo +東京 Tórshavn Tripoli Tskhinvali @@ -234,7 +236,7 @@ Tunis Ulaanbaatar Vaduz Valletta -Valparaíso­so +Valparaíso Vatican City Victoria Vienna diff --git a/src/menubar.rs b/src/menubar.rs index fd52810..bb6764b 100644 --- a/src/menubar.rs +++ b/src/menubar.rs @@ -8,6 +8,8 @@ use event::*; use std::rc::Rc; +use unicode_width::UnicodeWidthStr; + /// Current state of the menubar #[derive(PartialEq, Debug)] enum State { @@ -78,7 +80,7 @@ impl Menubar { (i == self.focus); printer.with_selection(selected, |printer| { printer.print((offset, 0), &format!(" {} ", title)); - offset += title.len() + 2; + offset += title.width() + 2; }); } } @@ -108,7 +110,7 @@ impl Menubar { self.state = State::Submenu; let offset = (self.menus[..self.focus] .iter() - .map(|&(ref title, _)| title.len() + 2) + .map(|&(ref title, _)| title.width() + 2) .fold(0, |a, b| a + b), if self.autohide { 1 diff --git a/src/view/button.rs b/src/view/button.rs index 36cd172..dfe4007 100644 --- a/src/view/button.rs +++ b/src/view/button.rs @@ -6,6 +6,7 @@ use vec::Vec2; use view::{View}; use event::*; use printer::Printer; +use unicode_width::UnicodeWidthStr; /// Simple text label with a callback when ENTER is pressed. /// A button shows its content in a single line and has a fixed size. @@ -44,7 +45,7 @@ impl View for Button { fn get_min_size(&self, _: Vec2) -> Vec2 { // Meh. Fixed size we are. - Vec2::new(2 + self.label.chars().count(), 1) + Vec2::new(2 + self.label.width(), 1) } fn on_event(&mut self, event: Event) -> EventResult { diff --git a/src/view/dialog.rs b/src/view/dialog.rs index a732352..fc49463 100644 --- a/src/view/dialog.rs +++ b/src/view/dialog.rs @@ -10,6 +10,8 @@ use view::{Button, SizedView}; use vec::{ToVec4, Vec2, Vec4}; use printer::Printer; +use unicode_width::UnicodeWidthStr; + #[derive(PartialEq)] enum Focus { Content, @@ -147,7 +149,7 @@ impl View for Dialog { printer.print_box(Vec2::new(0, 0), printer.size); if !self.title.is_empty() { - let len = self.title.chars().count(); + let len = self.title.width(); let x = (printer.size.x - len) / 2; printer.print((x - 2, 0), "┤ "); printer.print((x + len, 0), " ├"); @@ -181,7 +183,7 @@ impl View for Dialog { if !self.title.is_empty() { // If we have a title, we have to fit it too! - inner_size.x = max(inner_size.x, self.title.chars().count() + 6); + inner_size.x = max(inner_size.x, self.title.width() + 6); } inner_size diff --git a/src/view/edit_view.rs b/src/view/edit_view.rs index 0578495..673d4a6 100644 --- a/src/view/edit_view.rs +++ b/src/view/edit_view.rs @@ -1,4 +1,5 @@ use unicode_segmentation::UnicodeSegmentation; +use unicode_width::UnicodeWidthStr; use std::cmp::min; @@ -8,16 +9,19 @@ use view::{IdView, View}; use event::*; use printer::Printer; + /// Input box where the user can enter and edit text. pub struct EditView { - /// Current content + /// Current content. content: String, - /// Cursor position in the content + /// Cursor position in the content, in bytes. cursor: usize, - /// Minimum layout length asked to the parent + /// Minimum layout length asked to the parent. min_length: usize, - /// When the content is too long for the display, offset it + /// Number of bytes to skip at the beginning of the content. + /// + /// (When the content is too long for the display, we hide part of it) offset: usize, /// Last display length, to know the possible offset range last_length: usize, /* scrollable: bool, @@ -73,48 +77,62 @@ impl EditView { } } -fn remove_char(s: &mut String, cursor: usize) { - let i = match s.char_indices().nth(cursor) { - Some((i, _)) => i, - None => return, - }; - s.remove(i); -} - impl View for EditView { fn draw(&mut self, printer: &Printer) { // let style = if focused { color::HIGHLIGHT } else { color::HIGHLIGHT_INACTIVE }; - let len = self.content.chars().count(); + assert!(printer.size.x == self.last_length); + + let width = self.content.width(); printer.with_color(ColorStyle::Secondary, |printer| { printer.with_effect(Effect::Reverse, |printer| { - if len < self.last_length { + if width < self.last_length { + // No problem, everything fits. printer.print((0, 0), &self.content); - printer.print_hline((len, 0), printer.size.x - len, "_"); + printer.print_hline((width, 0), + printer.size.x - width, + "_"); } else { - let visible_end = min(self.content.len(), self.offset + self.last_length); + let content = &self.content[self.offset..]; + let display_bytes = content.graphemes(true) + .scan(0, |w, g| { + *w += g.width(); + if *w > self.last_length { + None + } else { + Some(g) + } + }) + .map(|g| g.len()) + .fold(0, |a, b| a + b); + + let content = &content[..display_bytes]; - let content = &self.content[self.offset..visible_end]; printer.print((0, 0), content); - if visible_end - self.offset < printer.size.x { - printer.print((printer.size.x - 1, 0), "_"); + let width = content.width(); + + if width < self.last_length { + printer.print_hline((width, 0), + self.last_length - width, + "_"); } } }); // Now print cursor if printer.focused { - let c = if self.cursor == len { + let c = if self.cursor == self.content.len() { "_" } else { // Get the char from the string... Is it so hard? - self.content + self.content[self.cursor..] .graphemes(true) - .nth(self.cursor) + .next() .expect(&format!("Found no char at cursor {} in {}", self.cursor, self.content)) }; - printer.print_hline((self.cursor - self.offset, 0), 1, c); + let offset = self.content[self.offset..self.cursor].width(); + printer.print((offset, 0), c); } }); } @@ -137,25 +155,41 @@ impl View for EditView { Event::Char(ch) => { // Find the byte index of the char at self.cursor - match self.content.char_indices().nth(self.cursor) { - None => self.content.push(ch), - Some((i, _)) => self.content.insert(i, ch), - } + self.content.insert(self.cursor, ch); // TODO: handle wide (CJK) chars - self.cursor += 1; + self.cursor += ch.len_utf8(); } Event::Key(key) => { match key { Key::Home => self.cursor = 0, - Key::End => self.cursor = self.content.chars().count(), - Key::Left if self.cursor > 0 => self.cursor -= 1, - Key::Right if self.cursor < self.content.chars().count() => self.cursor += 1, - Key::Backspace if self.cursor > 0 => { - self.cursor -= 1; - remove_char(&mut self.content, self.cursor); + Key::End => self.cursor = self.content.len(), + Key::Left if self.cursor > 0 => { + let len = self.content[..self.cursor] + .graphemes(true) + .last() + .unwrap() + .len(); + self.cursor -= len; } - Key::Del if self.cursor < self.content.chars().count() => { - remove_char(&mut self.content, self.cursor); + Key::Right if self.cursor < self.content.len() => { + let len = self.content[self.cursor..] + .graphemes(true) + .next() + .unwrap() + .len(); + self.cursor += len; + } + Key::Backspace if self.cursor > 0 => { + let len = self.content[..self.cursor] + .graphemes(true) + .last() + .unwrap() + .len(); + self.cursor -= len; + self.content.remove(self.cursor); + } + Key::Del if self.cursor < self.content.len() => { + self.content.remove(self.cursor); } _ => return EventResult::Ignored, } @@ -165,20 +199,51 @@ impl View for EditView { // Keep cursor in [offset, offset+last_length] by changing offset // So keep offset in [last_length-cursor,cursor] // Also call this on resize, but right now it is an event like any other - if self.cursor >= self.offset + self.last_length { - self.offset = self.cursor - self.last_length + 1; - } else if self.cursor < self.offset { + if self.cursor < self.offset { self.offset = self.cursor; - } - if self.offset + self.last_length > self.content.len() + 1 { + } else { + // So we're against the right wall. + // Let's find how much space will be taken by the selection (either a char, or _) + let c_len = self.content[self.cursor..] + .graphemes(true) + .map(|g| g.width()) + .next() + .unwrap_or(1); + // Now, we have to fit self.content[..self.cursor] into self.last_length - c_len. + let available = self.last_length - c_len; + // Look at the content before the cursor (we will print its tail). + // From the end, count the length until we reach `available`. + // Then sum the byte lengths. + let tail_bytes = + tail_bytes(&self.content[self.offset..self.cursor], available); + self.offset = self.cursor - tail_bytes; + assert!(self.cursor >= self.offset); - self.offset = if self.content.len() > self.last_length { - self.content.len() - self.last_length + 1 - } else { - 0 - }; + } + + // If we have too much space + if self.content[self.offset..].width() < self.last_length { + let tail_bytes = tail_bytes(&self.content, self.last_length - 1); + self.offset = self.content.len() - tail_bytes; } EventResult::Consumed(None) } } + +// Return the number of bytes, from the end of text, +// which constitute the longest tail that fits in the given width. +fn tail_bytes(text: &str, width: usize) -> usize { + text.graphemes(true) + .rev() + .scan(0, |w, g| { + *w += g.width(); + if *w > width { + None + } else { + Some(g) + } + }) + .map(|g| g.len()) + .fold(0, |a, b| a + b) +} diff --git a/src/view/select_view.rs b/src/view/select_view.rs index 980f838..d875ae0 100644 --- a/src/view/select_view.rs +++ b/src/view/select_view.rs @@ -9,6 +9,8 @@ use event::{Event, EventResult, Key}; use vec::Vec2; use printer::Printer; +use unicode_width::UnicodeWidthStr; + struct Item { label: String, value: Rc, @@ -134,7 +136,7 @@ impl View for SelectView { self.scrollbase.draw(printer, |printer, i| { printer.with_selection(i == self.focus, |printer| { - let l = self.items[i].label.chars().count(); + let l = self.items[i].label.width(); let x = self.align.h.get_offset(l, printer.size.x); printer.print_hline((0, 0), x, " "); printer.print((x, 0), &self.items[i].label); @@ -149,7 +151,7 @@ impl View for SelectView { // we'll still return our longest item. let w = self.items .iter() - .map(|item| item.label.len()) + .map(|item| item.label.width()) .max() .unwrap_or(1); let h = self.items.len(); diff --git a/src/view/text_view.rs b/src/view/text_view.rs index 7ce2611..7d97236 100644 --- a/src/view/text_view.rs +++ b/src/view/text_view.rs @@ -7,6 +7,8 @@ use align::*; use event::*; use super::scroll::ScrollBase; +use unicode_width::UnicodeWidthStr; + /// A simple view showing a fixed text pub struct TextView { content: String, @@ -40,7 +42,7 @@ fn get_line_span(line: &str, max_width: usize) -> usize { // (Or use a common function? Better!) let mut lines = 1; let mut length = 0; - for l in line.split(' ').map(|word| word.chars().count()) { + for l in line.split(' ').map(|word| word.width()) { length += l; if length > max_width { length = l; @@ -111,7 +113,7 @@ impl TextView { for line in self.content.split('\n') { height += 1; - max_width = max(max_width, line.chars().count()); + max_width = max(max_width, line.width()); } Vec2::new(max_width, height) @@ -150,7 +152,7 @@ impl<'a> Iterator for LinesIterator<'a> { let content = &self.content[self.start..]; if let Some(next) = content.find('\n') { - if content[..next].chars().count() <= self.width { + if content[..next].width() <= self.width { // We found a newline before the allowed limit. // Break early. self.start += next + 1; @@ -161,7 +163,7 @@ impl<'a> Iterator for LinesIterator<'a> { } } - let content_len = content.chars().count(); + let content_len = content.width(); if content_len <= self.width { // I thought it would be longer! -- that's what she said :( self.start += content.len(); @@ -209,7 +211,7 @@ impl View for TextView { self.scrollbase.draw(printer, |printer, i| { let row = &self.rows[i]; let text = &self.content[row.start..row.end]; - let l = text.chars().count(); + let l = text.width(); let x = self.align.h.get_offset(l, printer.size.x); printer.print((x, 0), text); });