文字っぽいの。

文字を書いています。写真も混ざります。

HHKB HYBRID Type-S 雪を "2枚" 買った。

キーボードを2枚使うとタイピング時の姿勢が良くなり、肩がこりにくくなる。つまり健康に良い。これは分割型のキーボードや自作キーボードが流行っていることからも分かる。

ならばHHKBを2枚使えば良いじゃないか。HHKBは最も使いやすいキーボードであり、最も打鍵感が良いキーボードである。

そんなわけで、以前まで有線式のHHKB Pro2を利用していた。

これでも十分な環境であったが、どうしても「有線である」というのが邪魔になってしまった。自分は仕事はMacを利用しているが、Windows機でFPSゲームをすることがある。FPSをするときにはHHKBの様な背の高いキーボードよりも、ロープロファイルなキーボードの方がプレイしやすい。そのため、ゲームをするたびにHHKBのケーブルを外してデスクをWindows仕様に変更し、ゲームを終えたらまた元の状態に戻している。これがなかなかに面倒で、特にケーブルの抜き差しが面倒なのである。

この課題を解決するためにHHKB HYBRID Type-S 雪を2台買った。

実際に配置してみた様子。パームレストFILCOなのは許して欲しい。

HYBRIDなら有線でもブルートゥースでも接続できるので前述した課題が解決できる。雪にしたのは単純にデザインがかわいかったから。印字が中央にあるのがとても良い。

HHKBを2枚使うメリット

分割式キーボードを買えばよいのでは?と思うかも知れないが、HHKB2枚使いには下記のようなメリットがある。

  • 入手が非常に容易
    • Amazonでも公式サイトでもいつでも購入できる
    • 自作系のキーボードとは異なり流通が安定しているのは壊れた時に安心(まぁ壊れないんだけど)
  • 壊れない
    • 以前まで利用していた有線式のHHKBの片方は大学院生の頃に購入したもので、まだ全然問題なく動く
    • ただしUSBの接続口だけは抜き差しで若干接続が悪くなってしまった
    • 自作キーボードのようにハンダ不良も発生しない
  • キー配列で困らない
    • 普通のキーボードが左右にあるだけなので、どんなホームポジションや運指でも安心
    • 分割式キーボードだと完璧なホームポジションと運指でないと、空中をタイピングしてしまう
  • 左右分割に飽きても安心
    • HHKBが2つ手に入るだけなので、もし飽きてしまっても売却したり、オフィス用と自宅用にすれば良い
    • 最初から分割式のものを購入してしまうと片方だけでは使えないので困ってしまう

HHKBを2枚使うデメリット

値段が高い!!!!!

が、この環境を手に入れてさえしまえば、働けなくなるまでこの環境で良くなる。今回購入したHHKB HYBRID Type-S 2枚は、自分が定年を迎えるまで使い続けることになるだろう。

また、この環境を手に入れるとキーボードを衝動買いしなくて良くなる。どのようなキーボードを購入したとしても、この環境を上回ることは無いのだ(自分の中では)。

まとめ

HHKBを2枚使うのは良いぞ。

今回購入したのは、英字配列のこれ。

日本語配列はこちら。


HHKBのここが好き!

HHKBのここが好き!
by Happy Hacking Keyboard - 一度使ったら戻れない極上のキータッチ | PFU

SwiftUIでTabキー単体のキーショートカットを実装する方法。

SwiftUIでのキーボードショートカット

SwiftUIではButtonやToggleに対して .keyboardShortcut(_:modifiers:) でキーボードショートカットを実装することができる。

Toggle("Toggle", isOn: $flag)
    .keyboardShortcut("1", modifiers: [.control])

Button("Button") {
    print("Control+2")
}
.keyboardShortcut("2", modifiers: [.control])

タブ単体では動かない

エディタアプリを作っているとTabキーで \t を入力させずに、インデント処理を行わせたい場合がある。そのため、上述したノリで、

Button("Button") {
    print("Tab")
}
.keyboardShortcut(.tab, modifiers: [])

と実装してみるが動かない。ちなみに、modifiers に色々と入れられるが、試してみて動いたのは Option + Tab の組み合わせのみだった。

解決方法

色々試したが、UITextViewUIViewRepresentable で利用する方法になった。エディタアプリを作る時にはSwiftUIの TextEditor では機能不足で、 UITextView を利用することが多いのでちょうどよいといえばちょうどよい。

まず、 keyCommands を登録したCustom UITextViewを用意する。

protocol MyTextViewDelegate: AnyObject {
    func myTextViewDidInputTab()
}

final class MyTextView: UITextView {
    override var keyCommands: [UIKeyCommand]? {
        let command = UIKeyCommand(title: "Shortcut Title", action: #selector(keyCommandTab(_:)), input: "\t", modifierFlags: [])

        // iOS 15以降ではこれを設定しないとEditor側への入力が優先される
        // ref: https://developer.apple.com/documentation/uikit/uikeycommand/3780513-wantspriorityoversystembehavior
        command.wantsPriorityOverSystemBehavior = true

        return [
            command
        ]
    }

    weak var myDelegate: MyTextViewDelegate?

    @objc func keyCommandTab(_ sender: UIKeyCommand) {
        myDelegate?.myTextViewDidInputTab()
    }
}

次にこれをSwiftUIで利用できるようにする。

struct TextView: UIViewRepresentable {
    private let onInputCommandTab = PassthroughSubject<Void, Never>()

    func makeCoordinator() -> Coordinator {
        return Coordinator(self)
    }

    func makeUIView(context: Context) -> MyTextView {
        let view = MyTextView()
        view.myDelegate = context.coordinator

        return view
    }

    func updateUIView(_ uiView: MyTextView, context: Context) {
        context.coordinator.parent = self
    }

    func onEvent(_ onInputCommandTab: (() -> Void)? = nil) -> some View {
        return onReceive(self.onInputCommandTab) {
            onInputCommandTab?()
        }
    }
}

extension TextView {
    final class Coordinator: NSObject, MyTextViewDelegate {
        fileprivate var parent: TextView

        init(_ parent: TextView) {
            self.parent = parent
        }

        func myTextViewDidInputTab() {
            parent.onInputCommandTab.send()
        }
    }
}

これで使う準備は整った。実際に使う時にはこうする。

import SwiftUI

struct ContentView: View {
    var body: some View {
        VStack() {
            TextView()
                .onEvent {
                    print("Input Tab.")
                }
        }
        .padding()
    }
}

これでTabを入力した時にはUITextViewに \t は入力されずに、イベントが発火して "Input Tab." がログ表示される。また、iPadで表示できるショートカット一覧にも、しっかりと登録されている。

これで無事にTabキーをハンドリングできるようになった。めでたし。

2022年の買ってよかったもの。

良くて使い続けてるものだけ書いていく。

デスク周り

fromatom.hatenablog.com

デスク周りをどかっとリニューアルした。コード周りもいい感じにまとまったし、デスクの色もダークブラウンになって満足している。

エスプレッソメーカー

ついに買ってしまった。

これがかなり良い。エスプレッソというものがそもそも旨い。 すぐに飽きてしまうかと思ったけど、なんだかんだで定期的に飲んでいる。

朝起きた時や、食後や、仕事中に眠くなった時などに、エスプレッソをガッと入れてグッと飲むと最高。 のんびりしたいときは、レンチンしたミルクを入れればカフェラテも作れる。 フォームミルクは面倒なので作らなくなった。だってミルクぶちこめば味同じだし。

コーヒーの粉を用意するのも面倒かと思ったが、購入したエスプレッソメーカーはポッドに対応していた。

これなら電源を入れて、ポッドをセットして、抽出して、ポッドを捨てればいいだけなのですごい簡単。

デカフェのインスタントコーヒー

これが一番美味い。

深夜作業をしていると、どうにもコーヒーが飲みたくなる時がある。 が、流石に深夜にコーヒーを飲んでしまうと、寝れなくなって終わってしまう。 そんな時に、このデカフェのインスタントコーヒーが便利。

味もかなり良いし、デカフェだからという違和感もないのでとても助かっている。

ココア

一番うまいココア。

ココアってお湯で作るとなんだか物足りない感じになり、牛乳で作りたくなりがち。 しかし、このスイスミスのココアはお湯で作ってもすごい濃厚でバリバリに美味い。最高。

マシュマロ入りとかヘーゼルナッツ風味とかあるけど、普通のミルクココアが安定して美味しいのでおすすめ。

AirTag

鍵につけるように、AppleのAirTagを買った。

ケースはこっち。

今まで鍵にはMAMORIOをつけていたけれど、電池交換ができない買い替え方式で困っていた。 AirTagならボタン電池の入れ替えが可能なので、長く使い続けられそうなので移行した。

キーケース

キーケースを買い替えた。

家の鍵ぐらいしか無いのでこれで十分。かわいいしコンパクトだし、革の質感も良い。

ソープディスペンサー

手洗いをしまくるようになったので、洗面台用に購入。 勝手に泡が出るのはやっぱり便利。

綿入れ半纏

www.amazon.co.jp

あったかい。圧倒的あったかさ。 冬場はこれとこたつだけで十分。

HDMIキャプチャー

Splatoon3を録画したくて買った。

パススルーもできるしUSB-Cで繋げばそのまま使えるしで便利。 OBSでそのままYouTube配信をしておいて、あとから見返したりしている。

1000円台で買える安いHDMIキャプチャーも試してみたけど、音がうまくとれなかったのでダメだった。 こういうのは最初からそこそこ良いやつを買っておいたほうが後悔しなくて済む。

CIO NovaPort TRIO

小型の充電器。このサイズで65WでるのでM2 MacBook Airの充電には十分。 M2 MacBook Airはすごい充電がもつので、出先で充電したことは無いが、保険のために持ち歩ける小ささと軽さが魅力。

ロジクール MX ANYWHERE 3

もう何年もAnyWhereシリーズを使い続けているが、3が出ていたので買い足してみた。 今までのAnyWhereシリーズとは異なり、勢いをつけてホイールを回すと無抵抗モードになるという機能が追加されている。おもしろい。

温素

入浴剤。東京に住んでいると、強塩泉系の温泉が多く硫黄系やアルカリ系の温泉に入るには少し遠出をしないといけない。 この入浴剤はアルカリ系の入浴剤になっていて、入れるとちゃんとヌルヌルする。 お湯がヌルヌルするのではなく、肌がヌルヌルするので溶けてる〜感がある。

詰め合わせパックを試してみて、琥珀の湯が気に入ったのででかいサイズのを購入した。

時代は着る布団。

かなり前から着る毛布が流行っており、冬場の寒さ対策グッズとして人気です。しかし、日本には古来から着る布団があります。

そう、半纏(綿入れ半纏)ですね。前から欲しかったのですが、Amazonブラックフライデーで安くなっていたので購入しました。もう売り切れてしまっていますが、下記の商品です。

早速使っているんですが、かなり暖かくて最高です。寒いとエアコンをつけて作業をするのですが、暖房を強くしすぎると暖かくはあるんですが頭がポワポワしてきます。一方で弱くすると薄っすらと寒さが身体にしみてきて、なんとも難しい。そんな時にこの半纏が便利です。

布団よろしく綿が全体に詰め込まれているので、もこもこのふかふかでとっても暖かい。寝れる。また、着る毛布のように長くないので、ちょっと席を立って物を取ったりとか、そういう動きがとても楽にできます。

デメリットとしては、もこもこしているのでベイマックスみたいになります。でかい。まぁこれで外に出るわけじゃないので良いでしょう。

今年の冬は、この半纏でぬくぬく快適に過ごせそうです。

UITextViewで `""` が `“”` に、 `--` が`—` に勝手に変換される問題の対処法

課題

UITextViewでエディタを作っていると、

  • "" が勝手に “”に変換される
  • '' が勝手に ‘’ に変換される
  • -- が勝手に に変換される

という状況に出会って困ることがあります。

これはなに?

これはスマート引用符とスマートダッシュという機能です。Macでコードや技術的な文章を書いている人は、

設定画面のこのチェックを外しましょうという記事に出会います。このスマート引用符とスマートダッシュiOS/iPadOSのUITextViewにも導入されているため、iPhone/iPadでも同様の自動変換が発生してしまいます。

対応策

OSの設定を変える

設定アプリ > 一般 > キーボード > スマート句読点をOFF

これでOS全体でスマート系(自動変換系)の機能がOFFになります。

UITextViewの設定を変える

OS側の設定でも変更可能ですが、わざわざOS設定を変えなくても特定のUITextViewでこれらの自動変換機能を止めたい場合があると思います。

UITextViewには smartQuotesTypesmartDashesTypeというプロパティがあるので、これらに .no を指定してあげるだけで良いです。

コード例としては、

override func viewDidLoad() {
    super.viewDidLoad()

    textView.smartDashesType = .no
    textView.smartQuotesType = .no
}

こんな感じ。

swift-markdownを利用してUITextViewのカーソル位置が特定記法であるか調べる。

やりたいこと

Markdownエディタを実装していくと、カーソルのいる場所がリストなのか太字なのか見出しなのかを知りたくなります。単純に記法によって見た目を変えたいのであれば、NSAttributedString.init(markdown:options:baseURL:) を使えばよいです。

一方で、エディタに太字ボタントグルボタン機能(はてなブログにもありますね)を作っていくと、「いまカーソルがいる場所は太字なのか」が知りたくなります。それが分かると

  • カーソル箇所が太字の場合:太字記法(****)を削除する
  • カーソル箇所が太字ではない場合:太字記法(****)を挿入する

といった処理を実装することが可能になります。

課題

太字かどうか確認する場合、簡素に考えると

  • カーソルがある行を取り出す
  • カーソル前後に文字列を2つに分割する
  • 分割した2つの文字列それぞれに ** があれば太字とする

という実装方法が思いつきます。これでもある程度は動くのですが、このように太字に指定される範囲が複数行にまたがる場合で困ります。

**これは
複数行にまたがる
太字**です

この場合カーソルがいる行だけ見ていては、太字かどうか判定できません。また、

`コードスパンなので**太字ではない**`

のように、使われる記号としては太字ではあるが、太字として解釈しない場合もあります。これらをまともに対応していくと、最終的に文章全体を解析して構文木を生成するMarkdownパーザを実装することになります。

実装方針

自分でMarkdownパーザを作るのも良いですが、すでにApple公式が作ったものがあるのでそちらを利用しましょう。

github.com

swift-markdowncmark-gfm を利用して実装されています。

カーソル位置を取得できるようにする

swift-markdownを使う前に、カーソル位置を取得できるようにしましょう。

import UIKit

final class ViewController: UIViewController {
    @IBOutlet weak var textView: UITextView! {
        didSet {
            textView.delegate = self
        }
    }
    @IBOutlet weak var label: UILabel!
}

extension ViewController: UITextViewDelegate {
    func textViewDidChangeSelection(_ textView: UITextView) {
        print(textView.selectedTextRange)
    }
}

こうすれば、カーソル位置がUITextRangeとして取得できます。

UITextRangeをSourceLocationに変換する

このカーソル位置をswift-markdownで利用している値に変換する必要があります。swift-markdownでは文章のどこに対象が存在しているかを示すために、SourceRangeが使われます。SourceRangeは

/// A range in a source file.
public typealias SourceRange = Range<SourceLocation>

https://github.com/apple/swift-markdown/blob/d491147940587dbadfb3472354f4d0c6e063e061/Sources/Markdown/Infrastructure/SourceLocation.swift#L64

と定義されているので、SourceLocationのRangeになります。そんなSourceLocationは

/// A location in a source file.
public struct SourceLocation: Hashable, CustomStringConvertible, Comparable {
    public static func < (lhs: SourceLocation, rhs: SourceLocation) -> Bool {
        if lhs.line < rhs.line {
            return true
        } else if lhs.line == rhs.line {
            return lhs.column < rhs.column
        } else {
            return false
        }
    }

    /// The line number of the location.
    public var line: Int

    /// The number of bytes in UTF-8 encoding from the start of the line to the character at this source location.
    public var column: Int

    /// The source file for which this location applies, if it came from an accessible location.
    public var source: URL?

    /// Create a source location with line, column, and optional source to which the location applies.
    ///
    /// - parameter line: The line number of the location, starting with 1.
    /// - parameter column: The column of the location, starting with 1.
    /// - parameter source: The URL in which the location resides, or `nil` if there is not a specific
    ///   file or resource that needs to be identified.
    public init(line: Int, column: Int, source: URL?) {
        self.line = line
        self.column = column
        self.source = source
    }

    public var description: String {
        let path = source.map {
            $0.path.isEmpty
                ? ""
                : "\($0.path):"
        } ?? ""
        return "\(path)\(line):\(column)"
    }
}

https://github.com/apple/swift-markdown/blob/d491147940587dbadfb3472354f4d0c6e063e061/Sources/Markdown/Infrastructure/SourceLocation.swift#L14

と定義されています。つまり、

  • line: 全体の何行目か
  • column: 行頭から何文字目か

という要素で対象の場所を示しています。ここでの注意点としては

  • カウントは0始まりではなく1始まるである
  • columnはUTF-8エンコードにおけるバイト数を用いる

事です。もうすでに面倒くさい匂いが漂っています。

さて、UITextViewの扱いに慣れた人は感づいていると思いますが、こんなカウント方法をとるRange要素はデフォルトには用意されていません。UITextViewでは UITextVIew.selectedRangeUITextView.selectedTextRange でそれぞれ NSRangeUITextRange が得られるのですが、それぞれざっくり説明すると

  • NSRange
    • locationlength が得られる
    • location: 文章の先頭からn文字目
    • length: locationからm文字分(範囲選択してないなら0)
  • UITextRange
    • startend が得られる
    • start: 文章の先頭からn文字目
    • end: 文章の先頭からm文字目(範囲選択していないなら start と同様

といったデータが得られます。何行かをそのまま返してくれるAPIは無いので、変換処理を愚直に実装していきます。

func convertToSourceLocation(from selectedTextRange: UITextRange?, in textView: UITextView) -> SourceLocation? {
    guard let selectedTextRange else {
        return nil
    }

    let location = textView.offset(from: textView.beginningOfDocument, to: selectedTextRange.start)

    var lead: Int = 0
    var trail: Int = 0
    var currentLine: Int = 0
    var column: Int = location
    for line in textView.text.components(separatedBy: .newlines) {
        trail = lead + line.count

        if lead <= location && location <= trail {
            // SourceLocationのcolumnはUTF-8でカウントするので変換する
            let utf8BasedColumnCount = line.prefix(column).utf8.count

            // SourceLocationは1からカウントが始まる世界なので合わせる
            return SourceLocation(line: currentLine + 1, column: utf8BasedColumnCount + 1, source: nil)
        }

        currentLine += 1
        column -= line.count + "\n".count  // 改行コード分を追加で減らす
        lead = trail + 1
    }

    return nil
}

これでUITextRangeをSourceLocationに変換できました。

MarkupWalkerでカーソル位置が太字か確かめる

ようやく swift-markdownを使う段階に来ました。swift-markdownではVisitor patternを推奨しています。詳しい説明は省くのですが、公式ドキュメントのVisitors, Walkers, and Rewriters を読むとなんとなく雰囲気がつかめると思います。

さっそく、MarkupWalker のprotocolを利用して実装をします。

struct StrongWalker: MarkupWalker {
    var isStrong = false
    var cursorSourceLocation: SourceLocation

    mutating func visitStrong(_ strong: Strong) -> () {
        if let range = strong.range, range.contains(cursorSourceLocation) {
            isStrong = true
            return
        }

        descendInto(strong)
    }
}

このStrongWalkerとSourceLocationの変換処理を利用すると、下記の実装ができます。

extension ViewController: UITextViewDelegate {
    func textViewDidChangeSelection(_ textView: UITextView) {
        guard let cursorSourceLocation = convertToSourceLocation(from: textView.selectedTextRange, in: textView) else {
            return
        }

        let document = Document(parsing: textView.text)
        var strongWalker = StrongWalker(cursorSourceLocation: cursorSourceLocation)
        strongWalker.visit(document)

        print(strongWalker.isStrong) // => カーソルが太字記法内部にいるとtrue
    }
}

簡単なUIを作って様子を見る

ようやくロジック部分が完成したので、簡単なUIを作ってちゃんと動いているか見てみましょう。UILabelをおいて

if strongWalker.isStrong {
    label.text = "太字だよ"
} else {
    label.text = "太字じゃない"
}

という簡単なコードを追加します。

カーソル位置が太字か判定している様子

無事に動いていそうでめでたいですね。

注意点

swift-markdownMarkdownとして壊れた文字列でも問題なく解析してくれます。しかし、その場合はSourceRange, SourceLocationが正常に見えるが間違っている値で返ってきます。

まとめ

swift-markdownを使うことで、Markdownを解析することができます。しかし、swift-markdown内で使われるSourceLocationとSourceRangeは、UITextViewの世界とは異なる為に変換処理が必要かつ、1始まりのUTF-8カウントという特殊な世界です。

また、もしあなたがガッツリとMarkdownエディタを実装し始めたい場合は、 SourceRange <-> NSRange <-> UITextRange の変換迷宮に迷い込むでしょう。頑張って下さい。