4
2

More than 1 year has passed since last update.

Helix-Editorで日本語文章に対応した簡易的な文字移動を実装

Last updated at Posted at 2022-06-02

今回の内容は少しハックな内容なので注意

:point_right: githubからソースコードはクローンしているのが前提

壊れたり著しくパフォーマンスを欠くことはないが一応注意

試すだけならこの記事にあるコードをコピペしてcargo runでhelixを実行し,helix内でSpace+?でコマンドパレットを起動して関数を実行するだけで良い

まとめ

結果的には以下の様な動きになる

  • wを押し続けた場合

impl_w_move.gif

  • eを押し続けた場合

impl_e_move.gif

  • bを押し続けた場合

impl_b_move.gif

ひらがな,カタカナ,漢字それぞれを単語境界として移動できるようになった

helixではwが「次の単語の先頭の」,eが「次の単語の最後」となっている

そのため,ひらがな漢字などが連続している箇所ではweで単語が同じ箇所へ移動してしまう

また,既存の関数を利用しているので日本語以外に関しても,いつも通りの動作になっている

Helixでの基本移動について

Helixでは空白や改行以外がほぼ同じアルファベッドとして認識されている

そのため,日本語の漢字やカタカナ,絵文字などの区切り文字になりそうな文字も全て一つの単語として扱われてしまう

japanese_w_e_move.gif

そこで日本語のひらがな,カタカナ,漢字,全角の記号でw,e,bの単語移動を強化する関数を作った

Helixの内部を読む

HelixではUIや表示方法などそれぞれでCrateがわけられており,だいたいどこをいじればいいかが分かりやすい

今回のカーソル移動はエディタのCoreの部分にあたる

coreでは文字の扱い方や移動などエディタの基本機能が含まれている

もし,ポップアップやUIを変えたければそれこそviewやUIの箇所を重点的に見ればハックしやすい

それからもう一つ重要なのはhelix-term/src/commands.rsの箇所である

ここを覗いてもらえればわかるが,コマンドと説明,コマンドのための関数が全てここに集約されている

そのため,ここから関数を追加していくのが一番やりやすいと思う

関数と説明の追加

先ほど説明したcommands.rsstatic_commands!内で名前と説明を追加する

move系の箇所に置いた方が良いと思うが今回はお試しなので一番下に追加する

    #[rustfmt::skip]
    static_commands!(
        no_op, "Do nothing",
        ...
        command_palette, "Open command palette",

        move_next_japanese_word_start: "move next Japanese word start",
        move_next_japanese_word_end: "move next Japanese word end",
        move_prev_japanese_word_start: "move previous Japanese word start",
    );

今回の関数は移動のための関数なので同様にw, e, bの関数がどのように実装されているかを見る

fn move_word_impl<F>(cx: &mut Context, move_fn: F)
where
    F: Fn(RopeSlice, Range, usize) -> Range,
{
    let count = cx.count();
    let (view, doc) = current!(cx.editor);
    let text = doc.text().slice(..);

    let selection = doc
        .selection(view.id)
        .clone()
        .transform(|range| move_fn(text, range, count));
    doc.set_selection(view.id, selection);
}

fn move_next_word_start(cx: &mut Context) {
    move_word_impl(cx, movement::move_next_word_start)
}

fn move_prev_word_start(cx: &mut Context) {
    move_word_impl(cx, movement::move_prev_word_start)
}

fn move_next_word_end(cx: &mut Context) {
    move_word_impl(cx, movement::move_next_word_end)
}

このように代入する関数名だけ変わってmove_word_implに渡されているので,同様にそれぞれの関数を作ってパラメータだけ変更しておく

fn move_next_japanese_word_start(cx: &mut Context) {
    move_word_impl(cx, movement::move_next_japanese_word_start)
}

fn move_next_japanese_word_end(cx: &mut Context) {
    move_word_impl(cx, movement::move_next_japanese_word_end)
}

fn move_prev_japanese_word_start(cx: &mut Context) {
    move_word_impl(cx, movement::move_prev_japanese_word_start)
}

あたりまえだが,movementでエラーが発生する

そこでhelix-core/src/movements.rsへ移動する

movements.rsでの移動関数の実装を読む

movements.rsではファイル名からわかる通り,カーソルの移動系の関数が全て実装されている

move_next_word_start関数を検索すると,今度はenumの箇所だけ変わってword_moveに渡されている

pub fn move_next_word_start(slice: RopeSlice, range: Range, count: usize) -> Range {
    word_move(slice, range, count, WordMotionTarget::NextWordStart)
}

pub fn move_next_word_end(slice: RopeSlice, range: Range, count: usize) -> Range {
    word_move(slice, range, count, WordMotionTarget::NextWordEnd)
}

pub fn move_prev_word_start(slice: RopeSlice, range: Range, count: usize) -> Range {
    word_move(slice, range, count, WordMotionTarget::PrevWordStart)
}

fn word_move(slice: RopeSlice, range: Range, count: usize, target: WordMotionTarget) -> Range {
    let is_prev = matches!(
        target,
        WordMotionTarget::PrevWordStart
            | WordMotionTarget::PrevLongWordStart
            | WordMotionTarget::PrevWordEnd
    );

    if (is_prev && range.head == 0) || (!is_prev && range.head == slice.len_chars()) {
        return range;
    }

    #[allow(clippy::collapsible_else_if)]
    let start_range = if is_prev {
        if range.anchor < range.head {
            Range::new(range.head, prev_grapheme_boundary(slice, range.head))
        } else {
            Range::new(next_grapheme_boundary(slice, range.head), range.head)
        }
    } else {
        if range.anchor < range.head {
            Range::new(prev_grapheme_boundary(slice, range.head), range.head)
        } else {
            Range::new(range.head, next_grapheme_boundary(slice, range.head))
        }
    };

    // Do the main work.
    (0..count).fold(start_range, |r, _| {
        slice.chars_at(r.head).range_to_target(target, r)
    })
}

Rangeがエディタ内部で使われているropey(ropeデータ構造)のスライスになっている

anchorとheadの二つがそれぞれがスライスの始端と終端を示す範囲になっている

Range(1, 9) 前方
T[his is a] sample text
Range(9, 1) 後方
T]his is a[ sample text
Range(1, 1) 一か所
T[]his is a sample text

word_move()では単純にenumでprevだったらrangeの範囲を逆にしているだけになっている

Do the main workと書かれた箇所を見る

するとfoldでスライスをrange_to_target()で処理しているのがわかる

range_to_target()を読む

トレイトは無視して中身を読む

    fn range_to_target(&mut self, target: WordMotionTarget, origin: Range) -> Range {
        let is_prev = matches!(
            target,
            WordMotionTarget::PrevWordStart
                | WordMotionTarget::PrevLongWordStart
                | WordMotionTarget::PrevWordEnd
        );

        // Reverse the iterator if needed for the motion direction.
        if is_prev {
            self.reverse();
        }

        // Function to advance index in the appropriate motion direction.
        let advance: &dyn Fn(&mut usize) = if is_prev {
            &|idx| *idx = idx.saturating_sub(1)
        } else {
            &|idx| *idx += 1
        };

        // Initialize state variables.
        let mut anchor = origin.anchor;
        let mut head = origin.head;
        let mut prev_ch = {
            let ch = self.prev();
            if ch.is_some() {
                self.next();
            }
            ch
        };

        // Skip any initial newline characters.
        while let Some(ch) = self.next() {
            if char_is_line_ending(ch) {
                prev_ch = Some(ch);
                advance(&mut head);
            } else {
                self.prev();
                break;
            }
        }
        if prev_ch.map(char_is_line_ending).unwrap_or(false) {
            anchor = head;
        }

        // Find our target position(s).
        let head_start = head;
        #[allow(clippy::while_let_on_iterator)] // Clippy's suggestion to fix doesn't work here.
        while let Some(next_ch) = self.next() {
            if prev_ch.is_none() || reached_target(target, prev_ch.unwrap(), next_ch) {
                if head == head_start {
                    anchor = head;
                } else {
                    break;
                }
            }
            prev_ch = Some(next_ch);
            advance(&mut head);
        }

        // Un-reverse the iterator if needed.
        if is_prev {
            self.reverse();
        }

        Range::new(anchor, head)
    }

range(anchor, head)からわかる通り,anchor位置と移動後のheadの位置までの範囲を返している

ここでもprevなら逆にするという処理をしている

headが変更されている箇所を読み進めるとadvanceで定義されたクロージャでindexを変えてrangeの範囲を変えているのがわかる

また,今回は扱わないが改行の処理もここでやっているので,改行で移動したくない場合はこれをいじればカーソル移動を制御できそう

headはどう変化しているかを見るとFind our target position(s)からreached_target()に渡されているのがわかるので更に移動する

reached_target()

reaced_target()とそこで使われている関数

ようやっとこのファイル内での最終地点に到達した

fn is_word_boundary(a: char, b: char) -> bool {
    categorize_char(a) != categorize_char(b)
}

fn reached_target(target: WordMotionTarget, prev_ch: char, next_ch: char) -> bool {
    match target {
        WordMotionTarget::NextWordStart | WordMotionTarget::PrevWordEnd => {
            is_word_boundary(prev_ch, next_ch)
                && (char_is_line_ending(next_ch) || !next_ch.is_whitespace())
        }
        WordMotionTarget::NextWordEnd | WordMotionTarget::PrevWordStart => {
            is_word_boundary(prev_ch, next_ch)
                && (!prev_ch.is_whitespace() || char_is_line_ending(next_ch))
        }
        WordMotionTarget::NextLongWordStart => {
            is_long_word_boundary(prev_ch, next_ch)
                && (char_is_line_ending(next_ch) || !next_ch.is_whitespace())
        }
        WordMotionTarget::NextLongWordEnd | WordMotionTarget::PrevLongWordStart => {
            is_long_word_boundary(prev_ch, next_ch)
                && (!prev_ch.is_whitespace() || char_is_line_ending(next_ch))
        }
    }
}

これもやっていることは単純で,連続するcharを比べて単語境界や空白,改行を見て移動範囲を決定しているのがわかる

ここの単語境界が今回のメインで実装する場所になる

空白や行末であれば区切りになるのは同じにしたのでこれは変えない

単語境界を正規表現でやっているのかを見てみると普通にcategorize_char()で文字の種類を判断しているので,最終目的地であるhelix-core/src/chars.rsをへ移動する

実装は後でやる

helix-core/src/chars.rsを読む

各単語や空白などをunicodeで分類し,enumでカテゴライズしている

#[derive(Debug, Eq, PartialEq)]
pub enum CharCategory {
    Whitespace,
    Eol,
    Word,
    Punctuation,
    Unknown,
}

#[inline]
pub fn categorize_char(ch: char) -> CharCategory {
    if char_is_line_ending(ch) {
        CharCategory::Eol
    } else if ch.is_whitespace() {
        CharCategory::Whitespace
    } else if char_is_word(ch) {
        CharCategory::Word
    } else if char_is_punctuation(ch) {
        CharCategory::Punctuation
    } else {
        CharCategory::Unknown
    }
}

単語以外の記号や改行は同じ分類で単語の種類分けだけを変えたい

char_is_word()では英数字ならtrueを返すcharの組み込みメソッドが使われている

pub fn char_is_word(ch: char) -> bool {
    ch.is_alphanumeric() || ch == '_'
}

unicodeでひらがな,カタカナ,漢字を分類できれば良いことが分かる

日本語のみに対応可能な実装

今回の実装は日本語のみだが,もう少し抽象化できそうな感じがする

ここまで来るのに疲れたので諦めました

セパレータなどをconfigで設定して日本語だけではなく特定の記号でも区切りとしてできる可能性がある

また,今回はunicodeでの分類だが,wasmでpluginを作れるようになれば形態素解析を用いた移動が可能となる

既にlinderasudachigoyaなど日本語の形態素解析の実装はあるのであとはpluginを待てばできそう

chars.rsでの実装

カテゴライズ用のenumを作る

日本語以外は普通に既存物を利用したいので元のカテゴリをバリアントとして内包しておく

このサイトから日本語のひらがな,カタカナ,漢字のunicodeの範囲を求める

この範囲には半角カナとか全角記号の一部とかを全て入れてはいない

それを使って単純にcharが,いずれかの範囲にあれば作成したenumで分類するようにする

#[inline]のアトリビュートは初めて知った

小さな関数とそれを呼び出す関数でインライン化する際などで高速化できる

今回のように「関数を呼び出す関数」と「小さな関数」が複数ある場合には有効だそう

#[derive(Debug, Eq, PartialEq)]
pub enum JapaneseCharCategory {
    CharCategory(CharCategory),
    Hiragana,
    Katakana,
    Kanji,
}

#[inline]
pub fn categorize_japanese_char(ch: char) -> JapaneseCharCategory {
    if char_is_hiragana(ch) {
        JapaneseCharCategory::Hiragana
    } else if char_is_katakana(ch) {
        JapaneseCharCategory::Katakana
    } else if char_is_kanji(ch) {
        JapaneseCharCategory::Kanji
    } else {
        let category: CharCategory = categorize_char(ch);
        JapaneseCharCategory::CharCategory(category)
    }
}

const HIRAGANA_CHAR_START: char = '\u{3040}';
const HIRAGANA_CHAR_END: char = '\u{309f}';
const KATAKANA_CHAR_START: char = '\u{30a0}';
const KATAKANA_CHAR_END: char = '\u{30ff}';
const KANJI_CHAR_START: char = '\u{4e00}';
const KANJI_CHAR_END: char = '\u{9faf}';

#[inline]
pub fn char_is_hiragana(ch: char) -> bool {
    ch >= HIRAGANA_CHAR_START && ch <= HIRAGANA_CHAR_END
}

#[inline]
pub fn char_is_katakana(ch: char) -> bool {
    ch >= KATAKANA_CHAR_START && ch <= KATAKANA_CHAR_END
}

#[inline]
pub fn char_is_kanji(ch: char) -> bool {
    ch >= KANJI_CHAR_START && ch <= KANJI_CHAR_END
}

movements.rsでの実装

JapanesePrevWordEndが入っているが,いらなければ消しても良い

こちらでも関数を使い分けるためのenumを実装し,既存のをバリアントとして内包しておく

日本語の単語境界を追加し,各モーションごとに止まりたい場所を決める

#[derive(Copy, Clone, Debug)]
pub enum JapaneseWordMotionTarget {
    WordMotionTarget(WordMotionTarget),
    NextJapaneseWordStart,
    NextJapaneseWordEnd,
    PrevJapaneseWordStart,
    PrevJapaneseWordEnd,
}

fn is_japanese_word_boundary(a: char, b: char) -> bool {
    categorize_japanese_char(a) != categorize_japanese_char(b)
}
fn reached_japanese_target(target: JapaneseWordMotionTarget, prev_ch: char, next_ch: char) -> bool {
    match target {
        JapaneseWordMotionTarget::NextJapaneseWordStart
        | JapaneseWordMotionTarget::PrevJapaneseWordEnd => {
            is_japanese_word_boundary(prev_ch, next_ch)
                && (char_is_line_ending(next_ch) || !next_ch.is_whitespace())
        }
        JapaneseWordMotionTarget::NextJapaneseWordEnd
        | JapaneseWordMotionTarget::PrevJapaneseWordStart => {
            is_japanese_word_boundary(prev_ch, next_ch)
                && (!prev_ch.is_whitespace() || char_is_line_ending(next_ch))
        }
        JapaneseWordMotionTarget::WordMotionTarget(word_target) => {
            reached_target(word_target, prev_ch, next_ch)
        }
    }
}

スペースや改行などは同じで,単語境界だけ今回のchars.rsで作ったカテゴライズの関数に変更した

rante_to_japanese_targetを利用するためにトレイトで定義を追加する

既存のを利用したいが上手く使えなかったので分けた

pub trait CharHelpers {
    fn range_to_target(&mut self, target: WordMotionTarget, origin: Range) -> Range;
    fn range_to_japanese_target(
        &mut self,
        target: JapaneseWordMotionTarget,
        origin: Range,
    ) -> Range;
}
    fn range_to_japanese_target(
        &mut self,
        target: JapaneseWordMotionTarget,
        origin: Range,
    ) -> Range {
        let is_prev_japanese_word = matches!(
            target,
            JapaneseWordMotionTarget::PrevJapaneseWordStart
                | JapaneseWordMotionTarget::PrevJapaneseWordEnd
        );

        // Reverse the iterator if needed for the motion direction.
        if is_prev_japanese_word {
            self.reverse();
        }

        // Function to advance index in the appropriate motion direction.
        let advance: &dyn Fn(&mut usize) = if is_prev_japanese_word {
            &|idx| *idx = idx.saturating_sub(1)
        } else {
            &|idx| *idx += 1
        };

        // Initialize state variables.
        let mut anchor = origin.anchor;
        let mut head = origin.head;
        let mut prev_ch = {
            let ch = self.prev();
            if ch.is_some() {
                self.next();
            }
            ch
        };

        // Skip any initial newline characters.
        while let Some(ch) = self.next() {
            if char_is_line_ending(ch) {
                prev_ch = Some(ch);
                advance(&mut head);
            } else {
                self.prev();
                break;
            }
        }
        if prev_ch.map(char_is_line_ending).unwrap_or(false) {
            anchor = head;
        }

        // Find our target position(s).
        let head_start = head;
        #[allow(clippy::while_let_on_iterator)] // Clippy's suggestion to fix doesn't work here.
        while let Some(next_ch) = self.next() {
            if prev_ch.is_none() || reached_japanese_target(target, prev_ch.unwrap(), next_ch) {
                if head == head_start {
                    anchor = head;
                } else {
                    break;
                }
            }
            prev_ch = Some(next_ch);
            advance(&mut head);
        }

        // Un-reverse the iterator if needed.
        if is_prev_japanese_word {
            self.reverse();
        }

        Range::new(anchor, head)
    }

これも既存のとまぜたかったが上手くいかなかったので分けた

targetだけで上手く分けられないかを考えているが挫折中

fn japanese_word_move(
    slice: RopeSlice,
    range: Range,
    count: usize,
    target: JapaneseWordMotionTarget,
) -> Range {
    let is_prev = matches!(
        target,
        JapaneseWordMotionTarget::PrevJapaneseWordStart
            | JapaneseWordMotionTarget::PrevJapaneseWordEnd
    );

    // Special-case early-out.
    if (is_prev && range.head == 0) || (!is_prev && range.head == slice.len_chars()) {
        return range;
    }

    #[allow(clippy::collapsible_else_if)] // Makes the structure clearer in this case.
    let start_range = if is_prev {
        if range.anchor < range.head {
            Range::new(range.head, prev_grapheme_boundary(slice, range.head))
        } else {
            Range::new(next_grapheme_boundary(slice, range.head), range.head)
        }
    } else {
        if range.anchor < range.head {
            Range::new(prev_grapheme_boundary(slice, range.head), range.head)
        } else {
            Range::new(range.head, next_grapheme_boundary(slice, range.head))
        }
    };

    // Do the main work.
    (0..count).fold(start_range, |r, _| {
        slice.chars_at(r.head).range_to_japanese_target(target, r)
    })
}

コマンド呼び出しのための関数を追加

pub fn move_next_japanese_word_start(slice: RopeSlice, range: Range, count: usize) -> Range {
    japanese_word_move(
        slice,
        range,
        count,
        JapaneseWordMotionTarget::NextJapaneseWordStart,
    )
}

pub fn move_next_japanese_word_end(slice: RopeSlice, range: Range, count: usize) -> Range {
    japanese_word_move(
        slice,
        range,
        count,
        JapaneseWordMotionTarget::NextJapaneseWordEnd,
    )
}

pub fn move_prev_japanese_word_start(slice: RopeSlice, range: Range, count: usize) -> Range {
    japanese_word_move(
        slice,
        range,
        count,
        JapaneseWordMotionTarget::PrevJapaneseWordStart,
    )
}

pub fn move_prev_japanese_word_end(slice: RopeSlice, range: Range, count: usize) -> Range {
    japanese_word_move(
        slice,
        range,
        count,
        JapaneseWordMotionTarget::PrevJapaneseWordEnd,
    )
}

終わり

とりあえずはこれで日本語以外はいつもの動作で日本語の時には今回作った関数が動作する

ひらがな,カタカナ,漢字がそれぞれの境界として判別される

もちろん上手くいっていない箇所もあり,helixではwが「単語の頭の前」に移動する

そのため,日本語文で空白がない場合は,「単語の頭の前」が「前の単語の後ろ」になるため,weが同じ挙動になってしまう

もう少しRustを上手く使えれば抽象化できそうだが,私の今の実力ではかなわないので諦めた

個人的な興味として形態素解析を使って文節ごとなどにも応用するのも考えていた

それをエディタに求めるほどのことをまだしていないので考えている途中

4
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
2