2025-11-29

よく分かるEmacs Lisp入力関数関連図

Emacsのキー入力関数ってなんか似たような名前が多くてすぐに理解できませんよね。調べてだいたい分かったつもりになっていても、しばらくしたら忘れている自信があります。なので図にしておきました。自分のために。

read_charread_key_sequenceread_key_sequence_vsread-key-sequenceread-key-sequence-vectorread_filtered_eventread-eventread-charread-char-exclusivecommand_looprecursive_edit_1read_minibufread-from-minibuffercompleting-read-defaultcompleting-readread-multiple-choiceread-keyread-char-choiceread-quoted-charx-popup-dialoglread.ckeyboard.csubr.elminibuf.c/minibuffer.elrmc.elsimple.elcompleting-read-functionmenu.c

Emacs Lisp リファレンスマニュアルで言うと「Reading Input (GNU Emacs Lisp Reference Manual)」に書いてある関数のことです。

Reading Input (GNU Emacs Lisp Reference Manual)

大別すると read_filtered_event を介して直接的に一つのイベントを読み取る関数群と、 read_key_sequence を介してキーマップによって決まる一続きのキー列を読み取る関数群とに分けられるようです。普段バッファの中で使っているのは後者ですね。コマンドループを通じて read_key_sequence を呼び出しています。それだけに read_key_sequence の方が複雑で難しいです。

read_filtered_event 系には三つの関数がありますが、文字入力イベントのみに限定するバージョン(read-charread-char-exclusive)と全てのイベントを読み取るバージョン(read-event)に分かれます。 read-charread-char-exclusive の違いは、文字以外のイベントが来たときにエラーにするか、排除して続行するかの違いです。

  • read_filtered_event
    • 文字のみ
      • 文字以外でエラー : read-char
      • 文字以外は無視 : read-char-exclusive
    • 全て : read-event

read-charread-char-exclusive は、 read-event に比べると次の処理が加わっています。

  • text-conversionの無効化と復元 (Androidの場合IMEによって直接バッファを書き替える仕組みが存在します)
  • 非文字イベント発生時のエラー(read-char)またはリトライ(read-char-exclusive)
  • switch-frameイベントの遅延 (非文字イベントだが特別扱い)
  • イベントタイプシンボルの文字コード化 (例えばtabを9にします)
  • 修飾キービットの正規化 (主にshiftとcontrolの処理です。例えば25ビット目(?\S-\0)が立っていてベース文字がアルファベット小文字なら大文字にしてビットを消します。control(26ビット目)が立っている文字を制御文字へ変換したりもします)

当然ですが read_filtered_event 系関数にはキーマップは作用しません。

read_filtered_eventread_key_sequence に共通な処理は read_char の中に色々入っています。 unread-command-events の処理とかキーマクロの再現に関するものとか。

2025-11-13

タッチスクリーンで慣性スクロールする

Android版のEmacsを使っているとすぐに慣性スクロールが無いことに気がつくでしょう。つまり画面をスワイプしてスクロールするときに、弾くように指を離したらそのまましばらく勢いでスクロールが継続して欲しいわけです。これが無いと指を離すたびに「ピタッ」とスクロールが止まるので、何画面分もスクロールしなければならないときにとても疲れます。

これは慣性スクロールを実装しなきゃダメかな……と憂鬱になりながら少し調べてみたところ、pixel-scroll.elpixel-scroll-*-momentum という名前の変数や関数が存在することに気がつきました。喜び勇んですぐに pixel-scroll-precision-use-momentumt にして、 pixel-scroll-precision-mode を有効にしてみましたが……あれ、何も変わりません。うーん、どうなっているんだろう。

Googleで検索したら次のredditの投稿が見つかりました。

How to config to enable pixel scroll precision momentum-based scrolling on Android? : r/emacs

おおー、素晴らしい!

というわけで次の設定をしたらちゃんと慣性が働くようになりました。

;; タッチによるスクロールをピクセル単位にする(必要?)
(setq touch-screen-precision-scroll t)

;; 慣性スクロールを有効にする
;; https://www.reddit.com/r/emacs/comments/1mtouxh/how_to_config_to_enable_pixel_scroll_precision/
(defun touch-scroll-momentum (_dx dy)
  (pixel-scroll-accumulate-velocity (- dy)))
(advice-add 'touch-screen-handle-scroll :before 'touch-scroll-momentum)
;; (2025-11-14追記:長押しスクロールが効かなくなってしまったので修正)
;;(keymap-global-set "<touchscreen-end>" 'pixel-scroll-start-momentum)
(defun my-touch-screen-handle-touch:scroll-end (event &rest _)
  (when (eq (car event) 'touchscreen-end)
    (pixel-scroll-start-momentum event)))
(advice-add 'touch-screen-handle-touch
            :before ;;afterではダメ
            'my-touch-screen-handle-touch:scroll-end)

(setq pixel-scroll-precision-use-momentum t)
(pixel-scroll-precision-mode)

ただ、私の環境だとorg-mode文書のスクロールはかなりカクつきます。org-modernやphscrollで色々凝ったことをしているせいかもしれませんが。

2025-11-12 ,

Emacsのツールバーをカスタマイズする

Android版のEmacsのためにツールバーをカスタマイズしました。

修正前の状態は次図の通り。

ツールバー修正前
図1: ツールバー修正前

今回はあくまで一番上の標準的なツールバー(tool-bar-mode)のお話しです。その下にあるキー入力用のバーは無視してください(これもそのうち何とかしなきゃいけませんが)。

上図はデフォルトの状態なのですが、どのボタンが何をするか分かるでしょうか。私は正直よく分かっていませんでした。一番左からファイル新規作成、ファイルを開く、ディレクトリを開く、バッファを閉じる、セーブ、アンドゥ、カット、コピー、ペースト、インクリメンタル検索となっています。

一番問題なのは最初の「ファイル新規作成、ファイルを開く、ディレクトリを開く」の部分です。これって実際に何をするか分かりますか? Emacsでは通常全てfind-fileで行うものだと思います。実際に割り当てられているコマンドは左から、find-file、menu-find-file-existing、diredとなっています。find-fileだけでいいじゃん!

それとバッファを閉じるための×が押しにくいんですよね。一番左に配置しましょう。

カット、コピーは長押しのコンテキストメニューでやっているのでここには要らないかなーと思います。ペーストくらいは残しておいても良いかな? この辺りはまた後で変えるかも。

他にもいくつかよく使う操作をツールバーのボタンにしたいと思います。

それと全体的にアイコンが小さすぎません? いや、これはデフォルトフォントサイズの設定に合わせて変わってしまっています。私はデフォルトフォントを少し小さめにしてしまったので、ツールバーのボタンも一緒に小さくなって押しづらくなってしまいました。そしてツールバーのボタンのサイズだけを調整する方法が見当たりません。tool-barフェイスの:heightを変更してみてもこれはテキストのみにしか効果が無いらしくアイコンのサイズは変わりませんでした。うーん困った。

で、色々やってみた結果がこちら。

ツールバー修正後
図2: ツールバー修正後

ボタンの数が減ってシンプルで分かりやすくなりました。ボタンのサイズも大きくなって押しやすくなりました。

一番右に追加したボタンはbeginning-of-bufferとend-of-bufferです。長いファイルの途中にいるときにタッチによるスクロールだけで大きく移動するのは大変です。かといってM-<やM->を押すのもなかなか面倒なので追加してみました。

作成したコードは次のようになりました。

;; 項目のカスタマイズ(既存項目の削除、並び順の変更)
;; tool-bar-mapを直接変更します。
;; tool-bar-setupが呼び出された後じゃないと正しく動作しません。

(defun my-tool-bar-map--customize-items (map)
  "tool-bar-mapの項目をカスタマイズします。"

  ;; new-file(find-file)のアイコンをopen-fileのものに変更
  (setf (plist-get (cdddr (alist-get 'new-file (cdr map))) :image)
        (plist-get (cdddr (alist-get 'open-file (cdr map))) :image))

  ;; 不要なアイコンを非表示
  ;;   - diredとopen-fileはnew-file(find-file)で代替できる
  ;;   - cutとcopyはコンテキストメニューでできる
  ;;     (pasteは正確な位置指定のために一応残す)
  (dolist (key '(dired open-file cut copy))
    (setf (plist-get (cdddr (alist-get key (cdr map))) :visible) nil))

  ;; separator-2と3を削除
  (setf (alist-get 'separator-2 (cdr map) nil t) nil)
  (setf (alist-get 'separator-3 (cdr map) nil t) nil)

  ;; kill-bufferをツールバーの先頭に移動
  ;; kill-bufferの右にseparator
  (let ((item (assq 'kill-buffer (cdr map))))
    (setf (alist-get 'kill-buffer (cdr map) nil t) nil)
    (setf (alist-get 'separator-0 (cdr map) nil t) nil)
    (setcdr map (cons item
                      (cons
                       (list 'separator-0 "--")
                       (cdr map))))))

;; 項目の追加

(defconst my-tool-bar-map--additional-items
  '((top "Top" beginning-of-buffer "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\" version=\"1.1\"><path stroke=\"none\" d=\"M4 2H20V4H12L18 10H14V22H10V10H6L12 4H4Z\" fill=\"#444\" /></svg>")
    (bottom "Bottom" end-of-buffer "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\" version=\"1.1\"><path stroke=\"none\" d=\"M4 22H20V20H12L18 14H14V2H10V14H6L12 20H4Z\" fill=\"#444\" /></svg>")))

(defun my-tool-bar--add-item (map id text cmd image-data)
  (setcdr map (assq-delete-all id (cdr map)))
  (setcdr map (append
               (cdr map)
               (list
                (list id 'menu-item text cmd
                      :image
                      (list 'quote
                            (create-image image-data
                                          'svg t :scale 'default)))))))

(defun my-tool-bar-map--customize-additional (map)
  (dolist (spec my-tool-bar-map--additional-items)
    (apply #'my-tool-bar--add-item map spec)))

;; アイコンサイズの調整
;; image descriptorに:scaleを無理矢理追加して調整します。

(defconst my-tool-bar--icon-scale 2.8) ;; ★要調整

(defun my-tool-bar--adjust-image-scale (image)
  (when image
    (setf (plist-get (cdr image) :scale) my-tool-bar--icon-scale))
  image)

(defun my-tool-bar--adjust-map-image-scale (map)
  (dolist (item (cdr map))
    (when-let* ((image (plist-get (cddddr item) :image)))
      (setf (plist-get (cddddr item) :image)
            (list 'my-tool-bar--adjust-image-scale image)))))

(defun my-tool-bar-map--customize-icon-size (map)
  (my-tool-bar--adjust-map-image-scale map))

;; 初期化

(defun my-tool-bar-map--customize ()
  (let ((map (default-value 'tool-bar-map)))
    (my-tool-bar-map--customize-items map)
    (my-tool-bar-map--customize-additional map)
    (my-tool-bar-map--customize-icon-size map)))

(if (and (boundp 'tool-bar-map) (cdr-safe (default-value 'tool-bar-map)))
    ;; すでにtool-bar-mapが初期化されているときは更新
    (progn
      (my-tool-bar-map--customize)
      (tool-bar--flush-cache)
      (force-mode-line-update))
  ;; まだの時は初期化されるまで待つ
  (advice-add 'tool-bar-setup :after #'my-tool-bar-map--customize))

Emacsのツールバーはカスタマイズ性が悪いですね。どうせみんな使ってないんでしょう? いや、私もPC上では使っていませんが。まさかこの期に及んでツールバーをカスタマイズすることになるとは思いませんでした。

(2025-11-13:追記)org-mode時の折りたたみ操作もやりづらいので、org-mode用の項目も追加しました。

(defvar my-org-tool-bar-map
  (let ((map (make-sparse-keymap)))
    (define-key-after map [separator-org-1] menu-bar-separator)
    (define-key-after map [fold]
      `(menu-item
        "Fold" my-org-fold-current-subtree
        :help "Fold current subtree"
        :image (create-image "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\" version=\"1.1\"><path d=\"M12 14 6 20H18\" fill=\"#444\" stroke=\"none\" /><path d=\"M12 10 18 4H6\" fill=\"#444\" stroke=\"none\" /></svg>" 'svg t :scale 'default)))
    map))

(defun my-org-tool-bar-map ()
  ;; make-composed-keymapは機能しない。
  ;; :imageの部分が展開されないから。(See: `tool-bar-make-keymap-1')
  ;; (make-composed-keymap (default-value 'tool-bar-map)
  ;;                       my-org-tool-bar-map)
  (let ((map (copy-keymap (default-value 'tool-bar-map))))
    (set-keymap-parent map my-org-tool-bar-map)
    map))

(defun my-org-fold-current-subtree ()
  "現在のサブツリーを折りたたみます。
ポイントが見出しにあり、その見出しがすでに折りたたまれている場合は、それ
を含む一つ上のサブツリーを折りたたみます。"
  (interactive)
  (if (org-at-heading-p)
      (if (org-fold-core-folded-p (pos-eol))
          ;; 折りたたまれている見出し上にいる場合
          (progn
            (outline-up-heading 1)
            (outline-hide-subtree))
        ;; 折りたたまれていない見出し上にいる場合
        (outline-hide-subtree)
        (unless (org-fold-core-folded-p (pos-eol))
          ;; 折りたためなかった場合、一つ上を試す
          ;; (空のエントリーの場合は折りたためない)
          (outline-up-heading 1)
          (outline-hide-subtree)))
    ;; 見出し以外にいる場合
    (outline-previous-heading)
    (outline-hide-subtree)))

(defun my-org-tool-bar-setup ()
  (setq-local tool-bar-map (my-org-tool-bar-map)))

(add-hook 'org-mode-hook 'my-org-tool-bar-setup)

org-modeの折りたたみ操作はキー操作においても常々不満があります。見出し上なら単に TAB で折りたためますが、エントリーの中では C-c C-p TAB としなければなりませんし、見出しの上でそれを含むサブツリーを折りたたむには C-c C-u TAB としなければならなかったりします。上に書いた my-org-fold-current-subtree コマンドは、どこであっても概ね狙った通りに折りたたむ(閉じる)ことが出来ます。

2025-11-07 , , ,

PCから電子ペーパーに予定表を表示する(Waveshare13.3インチ6色)

前回、PCからUSB-SPI変換基板を通じて3.5インチ(4色)の電子ペーパーモジュールに画像を表示できました。

しかしやはり3.5インチでは小さくて物足りません。ここはもっと大きなパネルに表示させてみたいところ。

もっと大きなパネルを購入

しかし大きな電子ペーパーモジュールはどこで買えるのでしょうか。検索してもなかなか良いものが見つかりません。国内の電子工作部品を扱っているショップには10インチを超える製品の取扱が少ないようです。そんな中で見つけたのがAmazonで売っていた13.3インチ(6色)の製品。

13.3インチ SPI カラー 1600×1200 Full-Color 電子ペーパー モジュール E-ink Epaper ディスプレイ スクリーン HAT スターターキット for Arduino RPI Raspberry Pi Zero 2 W 3 4B 4 Model B 5 ボード 互換性 8GB 16GB RAM ラズベリーパイ 基板 ラズパイゼロ 2W 電子工作 部品 アクセサリー

出荷元/販売元はSTEMDIYという住所が中国の業者。おそらく海外から発送されてくるパターンでしょう。実際に表示できたという写真付きのレビューもありました。なのでいったんはここで注文したのですが、少々勘違いがあってすぐにキャンセルしてしまいました。またすぐに勘違いに気がつきましたが、キャンセルのキャンセルは出来ないので普通にキャンセル確定。更にすぐにもう一度注文するのも気が引けたので、落ち着いてネットで再度探してみるとAliExpressでもう少し安い値段で売られているのを見つけました。

13.3インチE Ink Spectra 6 (E6)フルカラー電子ペーパーディスプレイ 1600x1200ピクセル 価格タグや棚ラベルに最適 - AliExpress 44

もうこれでいいやと思い「with driver HAT」と書かれている方を注文。支払いはコンビニ払いで。ファミリーマートに行ってレジでバーコードを見せて38757円を支払いました。AliExpressは初めてです。大丈夫なのかなこれ。

翌日の15時くらいに発送の連絡が来て、中国での集荷、空港着、日本の空港着、通関完了、配送業者着と逐一連絡が届き、最終的には佐川急便の手によって自宅に届いたのが発送から8日後でした。

開封の儀

到着した箱はビニールテープでグルグル巻きにされていました。テープの接着剤の臭いがします。

到着した箱
図1: 到着した箱

何この綺麗な箱!

Waveshare 13.3インチ e-Paper(E6) 外箱
図2: Waveshare 13.3インチ e-Paper(E6) 外箱

中も丁寧に梱包されています。

Waveshare 13.3インチ e-Paper(E6) 内容
図3: Waveshare 13.3インチ e-Paper(E6) 内容

パネルは、薄くて軽い金属板のようです。「紙」とはいってもしなやかさはありません。

Waveshare 13.3インチ e-Paper(E6) E-Inkパネル
図4: Waveshare 13.3インチ e-Paper(E6) E-Inkパネル

付属品。「with Driver HAT」を選んでいたのでちゃんと駆動用のボード(HAT+)が付いてきました。間違えてたらどうしようと心配だったんです。よかった。

Waveshare 13.3インチ e-Paper(E6) Driver HAT+
図5: Waveshare 13.3インチ e-Paper(E6) Driver HAT+

ケーブル接続

マイコンボードに接続するのに最適なピンコネクタ付きケーブルは今回も付属しています。

Waveshare 13.3インチ e-Paper(E6) Driver HAT+
図6: Waveshare 13.3インチ e-Paper(E6) Driver HAT+

ただしピンの本数は10本。3.5インチモジュールは9本だったので1本増えています。CSがCS_MとCS_Sの二つになっています。

一方パネルと接続する側は、フィルム基板と接続するためのコネクタが付いています。

バックフリップ型FPCコネクタ
図7: バックフリップ型FPCコネクタ

このコネクタは後ろ側の黒い部分を持ち上げると緩むようになっており、フィルムを差し込んでから下ろすとロックされます。

FPCコネクタ接続後
図8: FPCコネクタ接続後

さて、これらをPCと接続するのには前回と同様にUSB-SPI変換基板を使用します。

USB-SPI変換基板 — スイッチサイエンス

HAT+とUSB-SPI変換基板との接続はCS信号が2本になっているためPmodコネクタだけではピンの数が足りません。USB-SPI変換基板にピンヘッダーを半田付けして、そこのIO7にCS_Sを接続することにしました。今回唯一の半田付け箇所です。

接続表:

e-Paper Pmod 2A MCP2210 Dir
#1(赤)VCC #6(VCC) VCC  
#2(黒)GND #5(GND) GND  
#3(青)DIN #2(MOSI) MOSI Out
#4(黄)SCLK #4(SCLK) SCLK Out
#5(橙)CS_M #1(CS) IO1 Out
#6(緑)CS_S - IO7 Out
#7(白)DC #9(CS2) IO2 Out
#8(紫)RST #8(RESET) IO0 Out
#9(茶)BUSY #7(INT) IO6 In
#10(灰)PWR #10(CS3) IO4 Out

画像の表示

早速PCと接続してプログラムを手直しして実行してみましたが、画像を表示させようとするとリフレッシュが始まって画面が少し書き換わった後に例外が発生して終了してしまいました。画面を白一色で塗りつぶすだけならうまく行くのですが、写真を表示させようとするとダメです。

Waveshareの資料を見ると3.3V/1A以上の電源を推奨と書かれていました。(https://www.waveshare.com/wiki/13.3inch_e-Paper_HAT+_(E)_Manual#Resources )

Question: When the main control board such as STM32, ESP32, and Arduino drives the e-Paper screen, it cannot be driven, and the main control board keeps restarting

Answer: The power supply is insufficient, the current required for the e-Paper screen and the driver board to run is relatively large, it is recommended to use a power supply of 3.3V/1A or more to power it

どうもバスパワーでは足りなそうです。幸いHAT+もUSB-SPI変換基板も5Vに対応しているので、適当なUSB電源アダプタから5Vを取ることにしました(電源の入れっぱなしが気になったので、後にPC連動型のセルフパワーUSBハブを使うようにしました)。こんなこともあろうかと先日スイッチサイエンスでUSB QI Cableを買っておいたのでそれを使います。GNDは共通にして、HAT+のVCCはUSB電源アダプタから取るようにしました。USB-SPI変換基板には5Vと3.5Vを切り替えるスイッチがあるので、それも5V側にしておきます。この辺りは電気に詳しい人なら色々と注意点があるところかもしれません。全ては自己責任でお願いします。

配線
図9: 配線

そうして再度実行すると、無事に画面の書き換えが完了しました。

写真の表示例
図10: 写真の表示例

転送には3~4分 、リフレッシュには20秒くらいかかりますが。

表示に使ったコードは次の通り。色々作りかけです。

# Usage: python epaper_print.py <imagefile>
import argparse
import time
import math
import logging
import mcp2210
import hid
from abc import ABC, abstractmethod
from typing import Sequence, Iterable
from PIL import Image, ImagePalette

logger = logging.getLogger(__name__)
logger.setLevel(logging.DEBUG)

# Bridge

class EPaperBridge(ABC):
    """ホストコンピュータからEPaperディスプレイへの通信を担うブリッジ"""

    @abstractmethod
    def close(self):
        ...

    @abstractmethod
    def power_on(self):
        ...

    @abstractmethod
    def reset(self):
        ...

    @abstractmethod
    def power_off(self):
        ...

    @abstractmethod
    def wait_until_not_busy(self):
        ...

    @abstractmethod
    def select_driver_chips(self, chip_numbers: Iterable[int]):
        ...

    @abstractmethod
    def send_command_code(self, _command_code: int):
        ...

    @abstractmethod
    def send_data_bytes(self, _data_bytes: bytes):
        ...

    def send_data(self, data: int | Sequence[int]):
        if isinstance(data, int):
            self.send_data_bytes(bytes([data]))
        elif isinstance(data, bytes):
            self.send_data_bytes(data)
        else:
            self.send_data_bytes(bytes(data))

    def send_command(self, command_code: int, *params: int | Sequence[int]):
        self.on_command_start()
        self.send_command_code(command_code)
        for p in params:
            self.send_data(p)
        self.on_command_end()

    def on_command_start(self):
        pass

    def on_command_end(self):
        pass

class EPaperBridgeMCP2210(EPaperBridge):
    """MCP2210を使用したブリッジ"""

    @staticmethod
    def is_mcp2210(device_dict):
        MCP2210_VENDOR_ID = 0x04d8
        MCP2210_PRODUCT_ID = 0x00de
        return (device_dict.get("product_id", -1) == MCP2210_PRODUCT_ID and
                device_dict.get("vendor_id", -1) == MCP2210_VENDOR_ID)

    @staticmethod
    def enumerate_mcp2210_devices():
        return list(filter(EPaperBridgeMCP2210.is_mcp2210, hid.enumerate()))

    @staticmethod
    def find_mcp2210_device():
        device_dicts = EPaperBridgeMCP2210.enumerate_mcp2210_devices()
        if len(device_dicts) == 0:
            raise RuntimeError("No MCP2210 devices found")
        elif len(device_dicts) >= 2:
            raise RuntimeError("Multiple MCP2210 devices found")
        return device_dicts[0]["serial_number"]

    def __init__(self):
        serial_number = EPaperBridgeMCP2210.find_mcp2210_device()
        self._pin_dc = 2
        self._pin_com_led = 3
        self._pin_busy = 6
        self._pin_rst = 0
        self._pin_pwr = 4
        self._cs_pins = [1, 7]
        self._cs_pins_selected = 1 << self._cs_pins[0] # Select chip#0
        #self._reset_wait_times = (0.2, 0.002, 0.2)
        self._reset_wait_times = (0.03, 0.03, 0.03, 0.03, 0.03)
        self._mcp = mcp2210.Mcp2210(serial_number, immediate_gpio_update=False)
        # self._mcp._spi_settings.bit_rate = 12000000
        self.init_mcp2210_for_paper()

    def close(self):
        self._mcp._hid.close()

    def init_mcp2210_for_paper(self):
        self._mcp.configure_spi_timing(chip_select_to_data_delay=0,
                                       last_data_byte_to_cs=0,
                                       delay_between_bytes=0)

        self.clear_mcp2210_gpio_status()
        # 13in3eはMCP2210のチップセレクト機能を使うと動作しない。
        # 明示的にチップセレクト信号を制御する。
        # for pin in self._cs_pins:
        #     self._mcp.set_gpio_designation(
        #         pin, mcp2210.Mcp2210GpioDesignation.CHIP_SELECT)
        for pin in self._cs_pins:
            self._mcp.set_gpio_output_value(pin, True)
        self._mcp.set_gpio_direction(self._pin_busy,
                                     mcp2210.Mcp2210GpioDirection.INPUT)
        self._mcp.set_gpio_output_value(self._pin_rst, True)
        self._mcp.set_gpio_output_value(self._pin_com_led, True)
        self._mcp.gpio_update()

    def clear_mcp2210_gpio_status(self):
        for pin_number in range(9):
            self._mcp.set_gpio_designation(pin_number,
                                           mcp2210.Mcp2210GpioDesignation.GPIO)
            self._mcp.set_gpio_direction(pin_number,
                                         mcp2210.Mcp2210GpioDirection.OUTPUT)
            self._mcp.set_gpio_output_value(pin_number, False)

    def power_on(self):
        self._mcp.set_gpio_output_value(self._pin_pwr, True)
        self._mcp.gpio_update()
        time.sleep(0.2)

    def reset(self):
        reset_value = True
        for wait_time in self._reset_wait_times:
            self._mcp.set_gpio_output_value(self._pin_rst, reset_value)
            self._mcp.gpio_update()
            reset_value = not reset_value
            time.sleep(wait_time)
        self.wait_until_not_busy()

    def power_off(self):
        self._mcp.set_gpio_output_value(self._pin_rst, False)
        self._mcp.set_gpio_output_value(self._pin_dc, False)
        self._mcp.set_gpio_output_value(self._pin_pwr, False)
        self._mcp.gpio_update()
        # self.clear_mcp2210_gpio_status(mcp)
        # self._mcp.gpio_update()

    def wait_until_not_busy(self):
        start_time = time.time()
        logger.debug("Start waiting (busy=%s)"
                     % self._mcp.get_gpio_value(self._pin_busy))
        # time.sleep(0.1)
        while(self._mcp.get_gpio_value(self._pin_busy) == False):
            time.sleep(0.005)
        logger.debug("End waiting (wait time=%s seconds)"
                     % (time.time() - start_time))

    def select_driver_chips(self, chip_numbers: Iterable[int]):
        bits = 0
        for chip in chip_numbers:
            bits |= 1 << self._cs_pins[chip]
        self._cs_pins_selected = bits

    def send_command_code(self, command_code: int):
        self._mcp.set_gpio_output_value(self._pin_dc, False)
        self._mcp.gpio_update()
        self.my_spi_exchange(bytes([command_code]))

    def send_data_bytes(self, data_bytes: bytes):
        self._mcp.set_gpio_output_value(self._pin_dc, True)
        self._mcp.gpio_update()
        self.my_spi_exchange(data_bytes)

    def on_command_start(self):
        for pin in range(9):
            if self._cs_pins_selected & (1 << pin):
                self._mcp.set_gpio_output_value(pin, False)
        self._mcp.gpio_update()

    def on_command_end(self):
        for pin in range(9):
            if self._cs_pins_selected & (1 << pin):
                self._mcp.set_gpio_output_value(pin, True)
        self._mcp.gpio_update()

    # -------------------------------------------------------------------
    # Derived from mcp2210.py
    # mcp2210.pyのspi_exchangeは一つのCSを指定しなければならないので改造する
    # Mcp2210Commands
    TRANSFER_SPI_DATA = 0x42
    # Mcp2210CommandResult
    SUCCESS = 0x00
    SPI_DATA_NOT_ACCEPTED = 0xF7
    TRANSFER_IN_PROGRESS = 0xF8
    # Mcp2210SpiTransferStatus
    SPI_TRANSFER_COMPLETE = 0x10
    SPI_TRANSFER_PENDING_NO_RECEIVED_DATA = 0x20
    SPI_TRANSFER_PENDING_RECEIVED_DATA_AVAILABLE = 0x30
    # Derived from spi_exchange()
    def my_spi_exchange(self, payload: bytes) -> bytes:
        # -- CHANGE BEGIN --
        mcp = self._mcp
        # cs_pin_bits = self._cs_pins_selected
        # mcp._spi_settings.active_chip_select_value = 0x01FF ^ cs_pin_bits
        mcp._spi_settings.active_chip_select_value = 0x01FF
        # -- CHANGE END --
        mcp._spi_settings.transfer_size = len(payload)
        mcp._set_spi_configuration()

        chunked_payload = []
        for i in range(math.ceil(len(payload) / 60)):
            start_index = i * 60
            stop_index = (i + 1) * 60
            chunk = bytes(payload[start_index:stop_index])
            chunked_payload.append(chunk)

        chunk_index = 0
        received_data = []
        while 1:
            if chunk_index == len(chunked_payload):
                next_chunk = b''
            else:
                next_chunk = chunked_payload[chunk_index]

            request = [EPaperBridgeMCP2210.TRANSFER_SPI_DATA, len(next_chunk), 0x00, 0x00]
            response = mcp._execute_command(bytes(request) + next_chunk, check_return_code=False)

            if response[1] == EPaperBridgeMCP2210.SPI_DATA_NOT_ACCEPTED:
                raise mcp2210.Mcp2210SpiBusLockedException
            elif response[1] == EPaperBridgeMCP2210.TRANSFER_IN_PROGRESS:
                # TODO: このあたりでC-cによって例外が起きると、続くshutdownが機能しない。
                time.sleep(0.005)
                continue
            elif response[1] == EPaperBridgeMCP2210.SUCCESS:
                # data was accepted, move to next chunk
                chunk_index += 1

                receive_data_size = response[2]
                spi_transfer_status = response[3]

                if spi_transfer_status == EPaperBridgeMCP2210.SPI_TRANSFER_PENDING_NO_RECEIVED_DATA:
                    continue
                elif spi_transfer_status == EPaperBridgeMCP2210.SPI_TRANSFER_PENDING_RECEIVED_DATA_AVAILABLE:
                    received_data.append(response[4:receive_data_size + 4])
                    continue
                elif spi_transfer_status == EPaperBridgeMCP2210.SPI_TRANSFER_COMPLETE:
                    received_data.append(response[4:receive_data_size + 4])
                    break
                else:
                    raise mcp2210.Mcp2210CommandFailedException("Encountered unknown SPI transfer status")
            else:
                raise mcp2210.Mcp2210CommandFailedException("Received return code 0x{:02X} from device".format(response[1]))

        combined_receive_data = b''.join(bytes(x) for x in received_data)
        if len(combined_receive_data) != len(payload):
            raise RuntimeError("Length of receive data does not match transmit data")

        return combined_receive_data
    # -------------------------------------------------------------------

# EPaperDisplay

class EPaperDisplayWaveshare(ABC):
    "Waveshare製EPaperディスプレイモジュール(HAT)の基底クラス"
    def __init__(self,
                 bridge: EPaperBridge,
                 width: int, height: int, bits_per_pixel: int,
                 palette: bytes, default_pixel_value: int,
                 all_driver_chip_numbers: Sequence[int]):
        self._bridge = bridge
        self._width = width
        self._height = height
        self._bits_per_pixel = bits_per_pixel
        self._palette = palette
        self._default_pixel_value = default_pixel_value
        self._all_driver_chip_numbers = all_driver_chip_numbers
        self.init()

    # Initialization

    def init(self):
        try:
            self._bridge.power_on()
            self._bridge.reset()
            self.init_driver_chips()
        except Exception:
            self._bridge.power_off()
            raise

    @abstractmethod
    def init_driver_chips(self):
        ...

    # Shutdown

    def shutdown(self):
        try:
            self.shutdown_driver_chips()
        finally:
            self._bridge.power_off()

    def shutdown_driver_chips(self):
        logger.debug("Shutdown driver chips")
        self._bridge.wait_until_not_busy()
        self.select_all_driver_chips()
        # # 02:Power OFF Command
        # self.power_off_panel()
        # 07:Deep Sleep Command
        self._bridge.send_command(0x07, 0xA5)
        time.sleep(2)

    # Panel Power Control

    def power_on_panel(self):
        # 04:Power ON Command
        self._bridge.send_command(0x04)
        self._bridge.wait_until_not_busy()

    def power_off_panel(self):
        # 02:Power OFF Command
        self._bridge.send_command(0x02, 0x00)
        self._bridge.wait_until_not_busy()

    # Driver Chip Selection

    def select_all_driver_chips(self):
        """コマンドの送信先を全ての駆動チップとします。"""
        self._bridge.select_driver_chips(self._all_driver_chip_numbers)

    def select_driver_chip(self, driver_chip_number):
        """コマンドの送信先を指定されたチップのみとします。"""
        self._bridge.select_driver_chips([driver_chip_number])

    # Frame

    @abstractmethod
    def set_frame_bytes(self, frame_bytes: bytes):
        ...

    @property
    def width(self) -> int:
        """パネル全体の水平方向のピクセル数です。"""
        return self._width

    @property
    def height(self) -> int:
        """パネル全体の垂直方向のピクセル数です。"""
        return self._height

    @property
    def default_pixel_value(self) -> int:
        """デフォルトのピクセル値です。基本的に「白」を意味する値です。"""
        return self._default_pixel_value

    @property
    def frame_bits_per_pixel(self) -> int:
        """ピクセルあたりのビット数です。基本的に8以下の値です。"""
        return self._bits_per_pixel

    @property
    def frame_pixels_per_byte(self) -> int:
        """1バイトあたりのピクセル数です。"""
        return 8 // self._bits_per_pixel

    def make_filled_frame_byte(self, pixel_value: int):
        """1バイトの中を指定されたpixel_valueで満たしたものを返します。"""
        bpp = self.frame_bits_per_pixel
        pixel_value = pixel_value & ((1 << bpp) - 1)
        frame_byte = 0
        bitpos = 8 - bpp
        while bitpos >= 0:
            frame_byte |= pixel_value << bitpos
            bitpos = bitpos - bpp
        return frame_byte

    @property
    def frame_line_nbytes(self) -> int:
        """フレームバッファの1行のバイト数を返します。"""
        return ((self._width + self.frame_pixels_per_byte - 1) //
                self.frame_pixels_per_byte)

    @property
    def frame_nbytes(self) -> int:
        """フレームバッファのバイト数を返します。"""
        return self.frame_line_nbytes * self.height

    def fill_frame_with_byte(self, frame_byte: int):
        """フレームの全バイトをframe_byteで満たします。"""
        self.set_frame_bytes(bytes([frame_byte]) * self.frame_nbytes)

    def fill_frame(self, pixel_value: int):
        """フレームの全ピクセルをpixel_valueで塗りつぶします。"""
        self.fill_frame_with_byte(self.make_filled_frame_byte(pixel_value))

    def clear_frame(self):
        """フレームの全ピクセルを白一色で塗りつぶします。"""
        self.fill_frame(self.default_pixel_value)


    def frame_image_palette(self) -> ImagePalette.ImagePalette:
        return ImagePalette.ImagePalette("RGB", self._palette)

    def convert_image_to_frame_bytes(self, image: Image.Image) -> bytes:
        """imageをselfの仕様に合わせたフレームバイト列へ変換します。"""
        palette_image = Image.new("P", (1, 1))
        palette_image.putpalette(self.frame_image_palette())

        quantized_image = image.convert("RGB").quantize(palette=palette_image)

        src_bytes = quantized_image.getdata()
        src_w, src_h = quantized_image.size

        return bytes(EPaperDisplayWaveshare.convert_pimage_bytes_to_frame_bytes(
            src_bytes, src_w, 0, 0, src_w, src_h,
            self.width, self.height, self.frame_bits_per_pixel))

    @staticmethod
    def convert_pimage_bytes_to_frame_bytes(
            src_bytes,
            src_pitch: int,
            src_x: int, src_y: int, src_w: int, src_h: int,
            dst_w: int, dst_h: int, dst_bits_per_pixel: int) -> bytearray:
        scan_w = min(src_w, dst_w)
        scan_h = min(src_h, dst_h)
        dst_line_nbytes = (dst_w // (8 // dst_bits_per_pixel))
        dst_pitch = dst_line_nbytes
        dst_bytes = bytearray(dst_line_nbytes * dst_h)
        for y in range(scan_h):
            src = src_pitch * (src_y + y) + src_x
            dst = dst_pitch * y
            dst_bitpos = 8
            dst_byte = 0
            dst_bitmask = (1 << dst_bits_per_pixel) - 1
            for x in range(scan_w):
                dst_bitpos -= dst_bits_per_pixel
                dst_byte |= ((int(src_bytes[src + x]) & dst_bitmask)
                             << dst_bitpos)
                if dst_bitpos < dst_bits_per_pixel:
                    dst_bytes[dst] = dst_byte
                    dst = dst + 1
                    dst_bitpos = 8
                    dst_byte = 0
            if dst_bitpos < 8:
                dst_bytes[dst] = dst_byte
        return dst_bytes

    # Panel Update

    def update_panel(self):
        """現在のフレームをディスプレイの表示に反映します。"""
        logger.debug("Refresh Start")
        self.select_all_driver_chips()
        # 04:Power ON Command
        self.power_on_panel()
        # 12:Display Refresh Command
        self._bridge.send_command(0x12, 0x00)
        self._bridge.wait_until_not_busy()
        # 02:Power OFF Command
        self.power_off_panel()
        logger.debug("Refresh End")

    def show_image(self, image: Image.Image):
        """imageを表示します。"""
        self.set_frame_bytes(self.convert_image_to_frame_bytes(image))
        self.update_panel()


class EPaperDisplayWaveshare3in5G(EPaperDisplayWaveshare):
    """Waveshare 3.5インチ(G)"""

    def __init__(self, bridge):
        super().__init__(
            bridge = bridge,
            width = 184,
            height = 384,
            bits_per_pixel = 2,
            palette = bytes((0,0,0)+(255,255,255)+(255,255,0)+(255,0,0)+
                            (0,0,0)*252),
            default_pixel_value = 1,
            all_driver_chip_numbers = [0]
        )

    def init_driver_chips(self):
        # See: epd3in5g.py -> EPD -> init
        # ??
        self._bridge.send_command(0x66, 0x49, 0x55, 0x13, 0x5D, 0x05, 0x10)
        # ??
        self._bridge.send_command(0x4D, 0x78)
        # 00:Panel setting Register
        self._bridge.send_command(0x00, 0x0F, 0x29)
        # 01:Power setting Register
        self._bridge.send_command(0x01, 0x07, 0x00)
        # 03:Power OFF Sequence Setting Register
        self._bridge.send_command(0x03, 0x10, 0x54, 0x44)
        # 06:Booster Soft Start Command
        self._bridge.send_command(0x06,
                                  0x0F, 0x0A, 0x2F, 0x25, 0x22, 0x2E, 0x21)
        # 50:VCOM and DATA Interval setting Register
        self._bridge.send_command(0x50, 0x37)
        # ??
        self._bridge.send_command(0x60, 0x02, 0x02)
        # 61:Resolution setting
        self._bridge.send_command(0x61,
                                  self._width>>8, self._width&255,
                                  self._height>>8, self._height&255)
        # ??
        self._bridge.send_command(0xE7, 0x1C)
        # E3:Power Saving Register
        self._bridge.send_command(0xE3, 0x22)
        # ??
        self._bridge.send_command(0xB6, 0x6F)
        self._bridge.send_command(0xB4, 0xD0)
        self._bridge.send_command(0xE9, 0x01)
        # 30:PLL Control Register
        self._bridge.send_command(0x30, 0x08)
        # # 04:Power ON Command
        # self.power_on_panel()

    # Frame

    def set_frame_bytes(self, frame_bytes: bytes):
        """frame_bytesを電子ペーパーに転送します(表示しない)。"""
        # 10:Data Start Transmission Register
        self._bridge.send_command(0x10, frame_bytes)


class EPaperDisplayWaveshare13in3E(EPaperDisplayWaveshare):
    """Waveshare 13.3インチ(E)"""

    def __init__(self, bridge):
        super().__init__(
            bridge = bridge,
            width = 1200,
            height = 1600,
            bits_per_pixel = 4,
            palette = bytes((0,0,0)+
                            (255,255,255)+
                            (255,255,0)+
                            (255,0,0)+
                            (0,0,0)+
                            (0,0,255)+
                            (0,255,0)+
                            (0,0,0)*249),
            default_pixel_value = 1,
            all_driver_chip_numbers = [0, 1]
        )

    def init_driver_chips(self):
        # See: epd13in3E.py -> EPD -> init
        # (Master)
        self.select_driver_chip(0)
        self._bridge.send_command(0x74, 0xC0, 0x1C, 0x1C, 0xCC,
                                  0xCC, 0xCC, 0x15, 0x15, 0x55)
        # (Master and Slave)
        self.select_all_driver_chips()
        self._bridge.send_command(0xF0, 0x49, 0x55, 0x13, 0x5D, 0x05, 0x10)
        self._bridge.send_command(0x00, 0xDF, 0x69)
        self._bridge.send_command(0x50, 0xF7)
        self._bridge.send_command(0x60, 0x03, 0x03)
        self._bridge.send_command(0x86, 0x10)
        self._bridge.send_command(0xE3, 0x22) # E3:Power Saving Register
        self._bridge.send_command(0xE0, 0x01)
        self._bridge.send_command(0x61, 0x04, 0xB0, 0x03, 0x20) # 61:Resolution setting
        # (Master)
        self.select_driver_chip(0)
        self._bridge.send_command(0x01, 0x0F, 0x00, 0x28, 0x2C, 0x28, 0x38)
        self._bridge.send_command(0xB6, 0x07)
        self._bridge.send_command(0x06, 0xE8, 0x28)
        self._bridge.send_command(0xB7, 0x01)
        self._bridge.send_command(0x05, 0xE8, 0x28)
        self._bridge.send_command(0xB0, 0x01)
        self._bridge.send_command(0xB1, 0x02)

    # Frame

    def set_frame_bytes(self, frame_bytes: bytes):
        """frame_bytesを電子ペーパーに転送します(表示しない)。"""
        line_pitch = self.frame_line_nbytes
        line_half = self.frame_line_nbytes // 2
        height = self.height
        frame_nbytes = self.frame_nbytes
        # 10:Data Start Transmission Register
        self.select_driver_chip(0)
        self._bridge.on_command_start()
        self._bridge.send_command_code(0x10)
        for y in range(height):
            print(f"\r{line_half*y}/{frame_nbytes}", end="", flush=True)
            self._bridge.send_data(
                frame_bytes[y * line_pitch :
                            y * line_pitch + line_half])
        self._bridge.on_command_end()

        self.select_driver_chip(1)
        self._bridge.on_command_start()
        self._bridge.send_command_code(0x10)
        for y in range(height):
            print(f"\r{line_half*(height+y)}/{frame_nbytes}", end="", flush=True)
            self._bridge.send_data(
                frame_bytes[y * line_pitch + line_half :
                            y * line_pitch + line_half + line_half])
        self._bridge.on_command_end()
        print(f"\r{frame_nbytes}/{frame_nbytes}", flush=True)

# Main

def main():
    parser = argparse.ArgumentParser(
        prog="epaper_print",
        usage="python epaper_print.py [options]")
    parser.add_argument("filename", help="image file name")
    # TODO: Add options for specify device types
    # parser.add_argument("--bridge") #--bridge=mcp2210:serial=0002193217:pin_dc=2:pin_pwr=4
    # parser.add_argument("--epaper") #--epaper=waveshare13in3e

    # TODO: Add panel clear option
    # parser.add_argument("--clear")

    # TODO: Add forced power off option
    # parser.add_argument("--poweroff")

    # TODO: Add image rotation option
    # parser.add_argument("--rotate")

    # TODO: Add verbose option
    # parser.add_argument("--verbose")

    args = parser.parse_args()

    logger.debug("Load image")
    im = Image.open(args.filename)
    logger.debug("Rotate image")
    im = im.rotate(-90, expand=True)

    logger.debug("Create bridge")
    bridge = EPaperBridgeMCP2210()
    logger.debug("Create EPaperDisplay")
    # epaper = EPaperDisplayWaveshare3in5G(bridge)
    epaper = EPaperDisplayWaveshare13in3E(bridge)

    try:
        logger.debug("Resize image")
        im = im.resize((epaper.width, epaper.height))
        logger.debug("Show image")
        epaper.show_image(im)

        # epaper.fill_frame_with_byte(0xaf)
        # epaper.update_panel()

        # epaper.clear_frame()
        # epaper.update_panel()
    finally:
        logger.debug("Shutdown")
        try:
            # TODO: 転送中等にC-cで中断するとうまくshutdownできないことがある。パネルの電源が入りっぱなしになると良くない。
            epaper.shutdown()
        finally:
            bridge.close()

    logger.debug("End")

if __name__ == "__main__":
    main()

前回の3.5インチモジュールとの一番の違いは、画面が左右二つに分割されていて制御するチップも二つに分かれていることです。そのためCS信号は二つ(CS_MとCS_S)あります。どちらかを選んでコマンドを送信することもありますし、二つのチップに同時に送信することもあります。詳しくはDouble-IC Programming Analysisを参照してください。

また、6色ディスプレイなので画像データの形式も違います。有効なピクセル値は 0:黒, 1:白, 2:黄, 3:赤, 4:青, 5:緑 の6通りです。1ピクセル4ビットで1バイトに2ピクセル入ります(左詰)。パネル全体の解像度は1200×1600ですが、上述のように左右で分割されているため600×1600を2回に分けて(CSを変えて)送信する必要があります。全体のバイト数は1200×1600/2=960000バイトになります。

細かい点では、初期化シーケンス、リセットタイミング、CS信号の出し方にも違いがありました。パネルの電源のON/OFFタイミングもサンプルコードレベルでは違いましたが、3.5インチでも13.3インチのやり方で問題なかったので13.3インチのやり方で統一しました(リフレッシュ前後でON/OFFする)。CS信号の出し方はmcp2210-pythonライブラリでは対応できない部分があったので、上のソースコードでは無理矢理解決しています。

ドキュメント化されていない仕様はWaveshareのサンプルコードが参考になります。

e-Paper/E-paper_Separate_Program/13.3inch_e-Paper_E/RaspberryPi/python at master · waveshareteam/e-Paper

ケース作り

無事に表示できたのは良いのですが、パネルや基板がむき出しでは扱いづらくて仕方ありません。何か手頃なケースは無いかとダイソーに探しに行きました。

最初はスチレンボードを切り貼りして額を作ろうかなと思ったのですが、A4のフォトフレームがちょうど良さそうな大きさでした。

フォトフレーム(A4、クリアファイル対応、白・黒) - 100均 通販 ダイソーネットストア【公式】

試しに買って帰ってはめ込んでみたところほぼピッタリでした。

ただし電子ペーパーパネルはA4よりも若干長辺が短いので窓からフィルム基板部分が見えてしまいます。この辺りは後でカバーでも作りましょう。

それとフィルム基板のコネクタ部分が枠内に収まりません。無理矢理曲げても良いのかもしれませんが、心配だったので枠をカットしました。この素材はMDF(中密度繊維板)というのでしょうか? カッターでサクサク切れました。

ダイソーのフォトレームを削った様子
図11: ダイソーのフォトレームを削った様子
ダイソーのフォトフレームに収めた様子
図12: ダイソーのフォトフレームに収めた様子

こうなると基板がプラプラしているのが大変邪魔です。テープでべたっと覆ってしまっても良いのですが、これもダイソーに手頃なケースがあったのでそれに入れてみました。

プチプラケース L - 100均 通販 ダイソーネットストア【公式】

若干高さが足りなかったのでCS_Sのピンを折り曲げて無理矢理収めました。

ケース内
図13: ケース内

後はポリプロピレンに使える両面テープでケースごと裏面に貼り付け。

フォトフレーム背面
図14: フォトフレーム背面

org-modeのagendaを表示する

で、一番やりたかったのがorg-agendaを表示すること。

色々調整した結果、次のように無事表示できました。

Org Agendaを表示した例
図15: Org Agendaを表示した例

写真だとそれほどでもありませんが、肉眼で見るとコントラスト比の低さは気になりますね。白が結構黒いです。

Org Agendaを表示した例
図16: Org Agendaを表示した例

次のMakefileでagendaのHTML生成、作業ディレクトリへコピー、画像化、表示までを行えます。(あらかじめorg-agenda-custom-commandsにhtmlファイル名を指定してorg-batch-store-agenda-viewsでhtmlファイルがエクスポートされるようにしておく必要があります)

EMACS = C:/my-program-dir/emacs-30.2/bin/emacs
CHROME = "C:/Program Files/Google/Chrome/Application/chrome.exe"
AGENDA_HTML_SRC = ~/my-org-agenda-html/agenda.html
MAGICK = magick
EPAPER_PRINT = python epaper_print.py

.PHONY: all
all: update-agenda upload

.PHONY: update-agenda
update-agenda:
        $(EMACS) -batch -l ~/.emacs.d/init.el -eval '(org-batch-store-agenda-views)'

tmp-agenda.html: $(AGENDA_HTML_SRC)
        cp $< $@

tmp-agenda.png: tmp-agenda.html
        $(CHROME) --headless --screenshot=$(abspath tmp-agenda.png) --window-size=1600,1300 --force-device-scale-factor=1 --hide-scrollbars $(abspath tmp-agenda.html)
        $(MAGICK) $@ -crop 1600x1200+0+0 +repage $@
# ↑下に空白が空いてしまう(95px)ので大きめに作ってImageMagickでカットする。
#  https://www.reddit.com/r/chrome/comments/1jsa174/chrome_headless_screenshot_omitting_bottom_95/
#  https://issues.chromium.org/issues/405165895

.PHONY: upload
upload: tmp-agenda.png
        $(EPAPER_PRINT) tmp-agenda.png

.PHONE: clean
clean:
        rm tmp-agenda.png
        rm tmp-agenda.html

予定表の見た目はEmacsとCSSの双方をうまく調整してやる必要があります。

私のEmacs側の設定は Org Agendaに天気・日の出日の入・月の状態を表示する に書きました。

そこではorg-agenda-custom-commands(org-agenda-export-html-style "<link rel=\"stylesheet\" type=\"text/css\" href=\"agenda.css\">") という指定を入れてあるので、htmlと同じディレクトリにある agenda.css が参照されます。

電子ペーパーに特化した調整をしたかったので、Makefileがあるディレクトリに電子ペーパー用CSSを配置し、そこに一時的にhtmlをコピーしてからChromeで画像化しています。

実際に使ったCSSは次の通りです。

/* agenda.css: org-agenda電子ペーパー用CSS */

html {
    margin: 0;
    padding: 0;
}
body {
    margin: 0;
    padding: 0;
    overflow: hidden;
    line-height: 1.5;
    /* 文字 */
    /*-webkit-font-smoothing: none;*/
    font-family:"MS Gothic", "Noto Sans JP", monospace;
    font-size: 26px;
    color: #000000;
    background-color: #ffffff;
}
body>pre {
    margin: 0;
    padding: 10px;
    font-family: inherit;
    /* 高さ固定2段組 */
    height: 100vh;
    box-sizing: border-box;
    column-width: calc((100vw - 10px - 20px - 10px) / 2);
    column-gap: 20px;
    /* 折り返しの回避 */
    white-space: pre;
    text-overflow: ellipsis;
    overflow: hidden;
}
/* ハイパーリンクの装飾を取消 */
a {
    color: inherit;
    background-color: inherit;
    font: inherit;
    text-decoration: none;
}
/* タイトル */
.org-agenda-structure {
    font-weight: bold;
    color: #ffffff;
    background-color: #102e80;
    padding: 4px 10px;
}
/* アジェンダ行TODOキーワード・タグ・優先度等 */
.org-todo, .org-modern-todo {
    color: #c00000;
    font-size: 75%;
    display: none;
}
.org-done, .org-modern-done {
    color: #20c060;
    font-size: 75%;
}
.org-tag, .org-modern-tag {
    border: 1px solid #555;
    font-size: 60%;
    color: #555;
}
.org-priority, .org-modern-priority {
    color: #ff00ff;
}
.org-hide {
    /* color: #ffffff; font-size: 8px; */
    display: none;
}
/* アジェンダ行内容 */
.org-agenda-done {color: #20c060;}
.org-agenda-diary {color: #c00040;}
.org-agenda-calendar-sexp {color: #506090;}
.org-scheduled {}
.org-scheduled-today {}
.org-scheduled-previously {color: #c00000;}
.org-upcoming-deadline {color: #ff0000;}
.org-imminent-deadline {color: #ff0000; font-weight: bold;}
/* タイムグリッド */
.org-time-grid {color: #506090;}
.org-agenda-current-time {color: #506090; font-weight: bold;}

/* 日付行(日付・曜日・付加情報) */
:root {
    --date-color: #000;
    --date-color-sat: #04f;
    --date-color-sun: #c00;
}
.org-agenda-date, .my-org-agenda-date-saturday, .org-agenda-date-weekend {
    font-size: 125%;
    font-weight: bold;
    border-left: 10px solid currentColor;
    padding-left: 4px;
    /* padding-top: 4px; */
}
.org-agenda-date {color: var(--date-color);}
.my-org-agenda-date-saturday {color: var(--date-color-sat);}
.org-agenda-date-weekend {color: var(--date-color-sun);}
.my-org-agenda-dow, .my-org-agenda-dow-saturday, .my-org-agenda-dow-weekend {
    font-size: 70%;
    font-weight: bold;
    padding-left: 2px;
    vertical-align: 2px;
}
.my-org-agenda-dow {color: var(--date-color);}
.my-org-agenda-dow-saturday {color: var(--date-color-sat);}
.my-org-agenda-dow-weekend {color: var(--date-color-sun);}
.my-org-agenda-date-info { /* 付加情報 */
    font-size: 77%;
    margin-left: 8px;
}
.my-org-agenda-date-info img[src*="/.jma-weather-cache/"] { /* 天気画像 */
    height: 2em;
    vertical-align: -30%;
}
.my-org-agenda-date-info img { /* 月画像 */
    height: 1.3em;
    vertical-align: -8%;
}

フォントはMS Gothicを使うとアンチエイリアシングが無くてシャープだったのでそれを使っています。

org-agendaがエクスポートするHTMLは一つの巨大なpre要素なので、文書構造を意識したスタイル指定は困難です。ただ、Emacs側のface名はHTMLのspan class=として反映されます。見た目を変えるためにEmacs側の挙動を変更する必要が多々ありました。

課題

一番の課題は転送速度の遅さでしょうか。3~4分はさすがに長すぎるような気もします。まぁ、1時間に1回更新するくらいならさほど問題にはなりませんが。MCP2210でも使い方によっては改善できるでしょうか。それともFT232Hのようなものを使った方が良いでしょうか。

2025-11-06 ,

PCから電子ペーパーを制御して遊ぶ(Waveshare 3.5インチ4色)

前々から電子ペーパーには興味があったのですが、なかなか手軽に遊べる製品が無くて二の足を踏んでいました。

とにかく高すぎるんですよ。ペーパー、紙というイメージとはほど遠い価格です。まぁ、所詮は微細加工技術で作った特殊なディスプレイに過ぎません。

それに良い製品があまりないということもあります。これも紙のように何にでも使えるというイメージからはほど遠い状態です。

私が欲しかったのは紙の代わりに電子的に「印刷」できるデバイスです。単にPCにUSBで接続して、任意の画像が表示できればそれだけで良いのです。それに予定表とかカレンダーとかを表示させてPCの電源が入っていなくても常に表示させておけるようなものが欲しかったのです。たかだかそれだけのために高価なペンデバイス付きのタブレットを買おうとは思いません。最近は7インチくらいであればデジタルフォトフレームとして使える電子ペーパー製品も探せば見つかるようにはなってきました。もうしばらく待てば安価で良い製品が手に入るようになるかもしれませんが、現状ではまだまだといったところです。

手に入らないのであれば自分で作るしかありません。幸い電子工作に使うための電子ペーパーモジュールはいろんな所で見かけます。

それらはRaspberry Pi等のマイコンボードから制御することを想定しているようですが、私はいつも疑問に思います。目の前にPCがあるのになんでそんなものから制御しなきゃならないのかと。PCのUSBに接続して制御できないものかと。

お買い物

というわけで条件に合う製品を探したところ、次のものが見つかりました。

Waveshareというメーカーの電子ペーパーモジュール(ドライバー基板付き)はSPIというシリアル通信方式(+いくつかの汎用IO)で制御できるみたいです。まずは手始めということで少し小さい3.5インチ(4色タイプ)のを選んでみました。失敗しても出費が痛くないので。

そしてそれをPCから制御するために選んだのがこのUSB-SPI変換基板です。PCからUSB経由で9つのGPIOと1つのSPIポートが制御可能です。この基板はMCP2210というチップを積んでいてPCからはHIDデバイスとして認識されるので専用のデバイスドライバーは不要なのだとか。その代わり大きなデータは64バイトのHIDレポートに分割して送信しなければならないので転送速度はあまり出ないようです。あまりスピードを重視しない目的であれば十分使えるでしょう。

ピンヘッダはUSB-SPI変換基板と電子ペーパーモジュール(Waveshare3.5インチ(G))を接続するのに必要になるものです。USB-SPI変換基板のPmodコネクタはメスで、電子ペーパーモジュールに付属するケーブルのコネクタもメスなので、間に挟むものが必要になります。

カートに入れて注文、支払いはGoogle Payで。営業日の午後に発送されて翌日ポストに入っていました。

Waveshare 3.5inch e-Paper Module(G)とCrescent USB-SPI変換基板
図1: Waveshare 3.5inch e-Paper Module(G)とCrescent USB-SPI変換基板

接続

USB-SPI変換基板上には二つのコネクタがあります。一つは普通のUSB Type-Cコネクタです。PCと接続するために使います。もう一つはPmodという規格(Digilent Pmod Interface Specification)のコネクタです(Type 2A)。MCP2210から制御対象へ接続する信号線のほとんどはこのPmodコネクタに繋がっています。一部余っている信号線(GPIOの一部)は基板に穴だけあってピンやコネクタはありません。3.5インチパネルを制御するだけならPmodコネクタに出ている分だけで足ります。(ちなみに13.3インチ6色カラー(E)だと1本足りません)

USB-SPI変換基板のPmodコネクタ:

#6:VDD #5:GND #4:SCK #3:MISO #2:MOSI #1:IO1
#12:VDD #11:GND #10:IO4 #9:IO2 #8:IO0 #7:IO6

Waveshare側のコネクタ(3.5inch e-Paper Module (G) - Waveshare Wiki):

#9:PWR #8:BUSY #7:RST #6:DC #5:CS #4:SCLK #3:DIN #2:GND #1:VCC

ピンの意味:

ピン名 役割
VCC 電源(3.3 V / 5 V 入力)
GND グラウンド
DIN SPI MOSI
SCLK SPIクロック
CS SPIチップ選択ピン(Lowでアクティブ)
DC データ / コマンド選択(Highでデータ、Lowでコマンド)
RST 外部リセットピン(Lowでアクティブ)
BUSY Busyステータスアウトプットピン
PWR Power on/off 制御
Waveshare 3.5inch e-Paper Module(G) 裏面
図2: Waveshare 3.5inch e-Paper Module(G) 裏面

Waveshareの電子ペーパーモジュールにはいかにもマイコンボードと接続しやすそうなケーブルが付属していました。

そのケーブル(+両方長いピンヘッダ)を使ってこの二つを次のように接続してみました。できるだけPmod規格の意味論に合わせた結線にしてあります。

接続表:

MCP2210 Pmod 2A e-Paper Dir
IO1 #1(CS) #5(CS) Out
MOSI #2(MOSI) #3(DIN) Out
MISO #3(MISO) - -
SCLK #4(SCLK) #4(SCLK) Out
GND #5(GND) #2(GND) -
VCC #6(VCC) #1(VCC) -
IO6 #7(INT) #8(BUSY) In
IO0 #8(RESET) #7(RST) Out
IO2 #9(CS2) #6(DC) Out
IO4 #10(CS3) #9(PWR) Out

となると後はソフトウェアですね。

Python用ライブラリのインストール

今回はPythonを使うことにします。ライブラリが揃っているみたいなので。MCP2210を制御するライブラリがすでにあり、ザッと見た感じ悪く無さそうなのでそれを使うことにしました。

pip install mcp2210-python

を実行したら依存しているhidapiパッケージのインストールでエラーが発生しました。(事前にVisual Studio Community 2022がインストール済みです)

>pip install mcp2210-python
Collecting mcp2210-python
  Downloading mcp2210_python-1.0.8-py3-none-any.whl.metadata (732 bytes)
Collecting hidapi (from mcp2210-python)
  Downloading hidapi-0.14.0.post4.tar.gz (174 kB)
  Installing build dependencies ... done
  Getting requirements to build wheel ... done
  Preparing metadata (pyproject.toml) ... done
Collecting setuptools>=19.0 (from hidapi->mcp2210-python)
  Using cached setuptools-80.9.0-py3-none-any.whl.metadata (6.6 kB)
Downloading mcp2210_python-1.0.8-py3-none-any.whl (10 kB)
Using cached setuptools-80.9.0-py3-none-any.whl (1.2 MB)
Building wheels for collected packages: hidapi
  Building wheel for hidapi (pyproject.toml) ... error
  error: subprocess-exited-with-error

  × Building wheel for hidapi (pyproject.toml) did not run successfully.
  │ exit code: 1
  ╰─> [29 lines of output]
      C:\Users\misohena\AppData\Local\Temp\pip-build-env-1spmig8n\overlay\Lib\site-packages\setuptools\dist.py:759: SetuptoolsDeprecationWarning: License classifiers are deprecated.
      !!

              ********************************************************************************
              Please consider removing the following classifiers in favor of a SPDX license expression:

              License :: OSI Approved :: BSD License
              License :: OSI Approved :: GNU General Public License v3 (GPLv3)

              See https://packaging.python.org/en/latest/guides/writing-pyproject-toml/#license for details.
              ********************************************************************************

      !!
        self._finalize_license_expression()
      running bdist_wheel
      running build
      running build_ext
      building 'hid' extension
      creating build\temp.win-amd64-cpython-314\Release\Users\misohena\AppData\Local\Temp\pip-install-ual6i7nx\hidapi_12f0f10a24b04ec4959d911d09e2c27e\hidapi\windows
      "C:\Program Files\Microsoft Visual Studio\2022\Community\VC\Tools\MSVC\14.44.35207\bin\HostX86\x64\cl.exe" /c /nologo /O2 /W3 /GL /DNDEBUG /MD -IC:\Users\misohena\AppData\Local\Temp\pip-install-ual6i7nx\hidapi_12f0f10a24b04ec4959d911d09e2c27e\hidapi\hidapi -IC:\app\Python314\include -IC:\app\Python314\Include "-IC:\Program Files\Microsoft Visual Studio\2022\Community\VC\Tools\MSVC\14.44.35207\include" "-IC:\Program Files\Microsoft Visual Studio\2022\Community\VC\Tools\MSVC\14.44.35207\ATLMFC\include" "-IC:\Program Files\Microsoft Visual Studio\2022\Community\VC\Auxiliary\VS\include" "-IC:\Program Files (x86)\Windows Kits\10\include\10.0.26100.0\ucrt" "-IC:\Program Files (x86)\Windows Kits\10\\include\10.0.26100.0\\um" "-IC:\Program Files (x86)\Windows Kits\10\\include\10.0.26100.0\\shared" "-IC:\Program Files (x86)\Windows Kits\10\\include\10.0.26100.0\\winrt" "-IC:\Program Files (x86)\Windows Kits\10\\include\10.0.26100.0\\cppwinrt" /TcC:\Users\misohena\AppData\Local\Temp\pip-install-ual6i7nx\hidapi_12f0f10a24b04ec4959d911d09e2c27e\hidapi\windows\hid.c /Fobuild\temp.win-amd64-cpython-314\Release\Users\misohena\AppData\Local\Temp\pip-install-ual6i7nx\hidapi_12f0f10a24b04ec4959d911d09e2c27e\hidapi\windows\hid.obj -DHID_API_NO_EXPORT_DEFINE
      hid.c
      "C:\Program Files\Microsoft Visual Studio\2022\Community\VC\Tools\MSVC\14.44.35207\bin\HostX86\x64\cl.exe" /c /nologo /O2 /W3 /GL /DNDEBUG /MD -IC:\Users\misohena\AppData\Local\Temp\pip-install-ual6i7nx\hidapi_12f0f10a24b04ec4959d911d09e2c27e\hidapi\hidapi -IC:\app\Python314\include -IC:\app\Python314\Include "-IC:\Program Files\Microsoft Visual Studio\2022\Community\VC\Tools\MSVC\14.44.35207\include" "-IC:\Program Files\Microsoft Visual Studio\2022\Community\VC\Tools\MSVC\14.44.35207\ATLMFC\include" "-IC:\Program Files\Microsoft Visual Studio\2022\Community\VC\Auxiliary\VS\include" "-IC:\Program Files (x86)\Windows Kits\10\include\10.0.26100.0\ucrt" "-IC:\Program Files (x86)\Windows Kits\10\\include\10.0.26100.0\\um" "-IC:\Program Files (x86)\Windows Kits\10\\include\10.0.26100.0\\shared" "-IC:\Program Files (x86)\Windows Kits\10\\include\10.0.26100.0\\winrt" "-IC:\Program Files (x86)\Windows Kits\10\\include\10.0.26100.0\\cppwinrt" /Tchid.c /Fobuild\temp.win-amd64-cpython-314\Release\hid.obj -DHID_API_NO_EXPORT_DEFINE
      hid.c
      hid.c(3161): warning C4267: '=': 'size_t' から 'int' に変換しました。データが失われているかもしれません。
      hid.c(4285): warning C4244: '=': 'Py_ssize_t' から 'int' への変換です。データが失われる可能性があります。
      creating C:\Users\misohena\AppData\Local\Temp\pip-install-ual6i7nx\hidapi_12f0f10a24b04ec4959d911d09e2c27e\build\lib.win-amd64-cpython-314
      "C:\Program Files\Microsoft Visual Studio\2022\Community\VC\Tools\MSVC\14.44.35207\bin\HostX86\x64\link.exe" /nologo /INCREMENTAL:NO /LTCG /DLL /MANIFEST:EMBED,ID=2 /MANIFESTUAC:NO /LIBPATH:C:\app\Python314\libs /LIBPATH:C:\app\Python314 /LIBPATH:C:\app\Python314\PCbuild\amd64 "/LIBPATH:C:\Program Files\Microsoft Visual Studio\2022\Community\VC\Tools\MSVC\14.44.35207\ATLMFC\lib\x64" "/LIBPATH:C:\Program Files\Microsoft Visual Studio\2022\Community\VC\Tools\MSVC\14.44.35207\lib\x64" "/LIBPATH:C:\Program Files (x86)\Windows Kits\10\lib\10.0.26100.0\ucrt\x64" "/LIBPATH:C:\Program Files (x86)\Windows Kits\10\\lib\10.0.26100.0\\um\x64" setupapi.lib /EXPORT:PyInit_hid build\temp.win-amd64-cpython-314\Release\Users\misohena\AppData\Local\Temp\pip-install-ual6i7nx\hidapi_12f0f10a24b04ec4959d911d09e2c27e\hidapi\windows\hid.obj build\temp.win-amd64-cpython-314\Release\hid.obj /OUT:build\lib.win-amd64-cpython-314\hid.cp314-win_amd64.pyd /IMPLIB:build\temp.win-amd64-cpython-314\Release\Users\misohena\AppData\Local\Temp\pip-install-ual6i7nx\hidapi_12f0f10a24b04ec4959d911d09e2c27e\hidapi\windows\hid.cp314-win_amd64.lib
      LINK : fatal error LNK1104: ファイル 'build\temp.win-amd64-cpython-314\Release\Users\misohena\AppData\Local\Temp\pip-install-ual6i7nx\hidapi_12f0f10a24b04ec4959d911d09e2c27e\hidapi\windows\hid.cp314-win_amd64.exp' を開くことができません。
      error: command 'C:\\Program Files\\Microsoft Visual Studio\\2022\\Community\\VC\\Tools\\MSVC\\14.44.35207\\bin\\HostX86\\x64\\link.exe' failed with exit code 1104
      [end of output]

  note: This error originates from a subprocess, and is likely not a problem with pip.
  ERROR: Failed building wheel for hidapi
Failed to build hidapi
error: failed-wheel-build-for-install

× Failed to build installable wheels for some pyproject.toml based projects
╰─> hidapi

何かをVC++でビルドしようとして.expファイルが無いからエラーになっているようですね。そしてそのパスが極めて奇妙。テンポラリディレクトリっぽいのに、あり得ないパスになっています。ははぁ、これは C:\ のせいだな……。

mkdir C:\tmp
set TEMP=\tmp
set TMP=\tmp
pip install hidapi

としたら無事にインストール成功。続く

pip install mcp2210-python

も成功しました。

デバイスの列挙

試しにUSB-SPI変換基板にアクセスしてみます。まずはHIDデバイスの列挙。

import hid

for device_dict in hid.enumerate():
    keys = list(device_dict.keys())
    keys.sort()
    for key in keys:
        print("%s : %s" % (key, device_dict[key]))
    print()

実行してみるとHIDデバイスの一覧が表示されます。マウスやキーボードなんかが出てきますね。その中に次のものがありました。

bus_type : 1
interface_number : 0
manufacturer_string : Microchip Technology Inc.
path : b'\\\\?\\HID#VID_04D8&PID_00DE#6&10c7683d&0&0000#{4d1e55b2-f16f-11cf-88cb-001111000030}'
product_id : 222
product_string : MCP2210 USB to SPI Master
release_number : 2
serial_number : 0002193217
usage : 1
usage_page : 65280
vendor_id : 1240

mcpP2210-pythonでIO3に接続されているLEDを点滅させる

USB-SPI変換基板のIO3にはLEDが接続されているので、ためしにそれを点滅させてみましょう。

import time
from mcp2210 import Mcp2210, Mcp2210GpioDesignation, Mcp2210GpioDirection

mcp = Mcp2210(serial_number="0002193217") # 上で見つけたシリアル番号を指定

mcp.configure_spi_timing(chip_select_to_data_delay=0,
                         last_data_byte_to_cs=0,
                         delay_between_bytes=0)

for i in range(9):
  mcp.set_gpio_designation(i, Mcp2210GpioDesignation.GPIO)
  mcp.set_gpio_direction(i, Mcp2210GpioDirection.OUTPUT)

mcp.set_gpio_output_value(3, True)
time.sleep(0.5)
mcp.set_gpio_output_value(3, False)
time.sleep(0.5)
mcp.set_gpio_output_value(3, True)
time.sleep(0.5)
mcp.set_gpio_output_value(3, False)
time.sleep(0.5)
mcp.set_gpio_output_value(3, True)

3.5インチ電子ペーパーパネルを制御してみる(塗りつぶし)

Waveshareが提供するサンプルコード( https://files.waveshare.com/wiki/3.5inch_e-Paper_Module_G/3in5_e-Paper_G.zip )を参考にしつつ次のようなコードを作成しました。

import time
from mcp2210 import Mcp2210, Mcp2210GpioDesignation, Mcp2210GpioDirection

PIN_CS = 1
PIN_DC = 2
PIN_COM_LED = 3
PIN_BUSY = 6
PIN_RST = 0
PIN_PWR = 4

PAPER_WIDTH       = 184
PAPER_HEIGHT      = 384

def init_mcp2210_for_paper(mcp):
    mcp.configure_spi_timing(chip_select_to_data_delay=0,
                             last_data_byte_to_cs=0,
                             delay_between_bytes=0)

    clear_mcp2210_gpio_status(mcp)
    mcp.set_gpio_designation(PIN_CS, Mcp2210GpioDesignation.CHIP_SELECT)
    mcp.set_gpio_direction(PIN_BUSY, Mcp2210GpioDirection.INPUT)
    mcp.set_gpio_output_value(PIN_RST, True)
    mcp.set_gpio_output_value(PIN_COM_LED, True)
    mcp.gpio_update()

def clear_mcp2210_gpio_status(mcp):
    for pin_number in range(9):
        mcp.set_gpio_designation(pin_number, Mcp2210GpioDesignation.GPIO)
        mcp.set_gpio_direction(pin_number, Mcp2210GpioDirection.OUTPUT)
        mcp.set_gpio_output_value(pin_number, False)

def power_on(mcp):
    mcp.set_gpio_output_value(PIN_PWR, True)
    mcp.gpio_update()
    time.sleep(0.2)

def reset(mcp):
    mcp.set_gpio_output_value(PIN_RST, True)
    mcp.gpio_update()
    time.sleep(0.2)
    mcp.set_gpio_output_value(PIN_RST, False)
    mcp.gpio_update()
    time.sleep(0.002)
    mcp.set_gpio_output_value(PIN_RST, True)
    mcp.gpio_update()
    time.sleep(0.2)
    wait_until_not_busy(mcp)

def wait_until_not_busy(mcp):
    time.sleep(0.1)
    while(mcp.get_gpio_value(PIN_BUSY) == False):
        time.sleep(0.005)

def send_command_code(mcp, command_code: int):
    mcp.set_gpio_output_value(PIN_DC, False)
    mcp.gpio_update()
    mcp.spi_exchange(bytes([command_code]), PIN_CS)

def send_data_bytes(mcp, data_bytes: bytes):
    mcp.set_gpio_output_value(PIN_DC, True)
    mcp.gpio_update()
    mcp.spi_exchange(data_bytes, PIN_CS)

def send_data(mcp,
              data: int | bytes | bytearray | tuple[int] | list[int]):
    if isinstance(data, int):
        send_data_bytes(mcp, bytes([data]))
    elif isinstance(data, bytes):
        send_data_bytes(mcp, data)
    else:
        send_data_bytes(mcp, bytes(data))

def send_command(mcp,
                 command_code: int,
                 *params: int | bytes | bytearray | tuple[int] | list[int]):
    send_command_code(mcp, command_code)
    for p in params:
        send_data(mcp, p)

def init_paper(mcp):
    send_command(mcp, 0x66, 0x49, 0x55, 0x13, 0x5D, 0x05, 0x10)
    send_command(mcp, 0x4D, 0x78)
    send_command(mcp, 0x00, 0x0F, 0x29)
    send_command(mcp, 0x01, 0x07, 0x00)
    send_command(mcp, 0x03, 0x10, 0x54, 0x44)
    send_command(mcp, 0x06, 0x0F, 0x0A, 0x2F, 0x25, 0x22, 0x2E, 0x21)
    send_command(mcp, 0x50, 0x37)
    send_command(mcp, 0x60, 0x02, 0x02)
    send_command(mcp, 0x61,
                 PAPER_WIDTH//256, PAPER_WIDTH%256,
                 PAPER_HEIGHT//256, PAPER_HEIGHT%256)
    send_command(mcp, 0xE7, 0x1C)
    send_command(mcp, 0xE3, 0x22)
    send_command(mcp, 0xB6, 0x6F)
    send_command(mcp, 0xB4, 0xD0)
    send_command(mcp, 0xE9, 0x01)
    send_command(mcp, 0x30, 0x08)
    send_command(mcp, 0x04)
    wait_until_not_busy(mcp)

def fill_all_pixels(mcp, color_byte: int = 0x55):
    size = ((PAPER_WIDTH + 3) // 4) * PAPER_HEIGHT
    send_command(mcp, 0x10, [color_byte] * size)

def refresh_display(mcp):
    send_command(mcp, 0x12, 0x00)
    wait_until_not_busy(mcp)

def shutdown_paper(mcp):
    send_command(mcp, 0x02, 0x00) # POWER_OFF
    time.sleep(0.1)
    send_command(mcp, 0x07, 0xa5) # DEEP_SLEEP
    time.sleep(2);

    mcp.set_gpio_output_value(PIN_RST, False)
    mcp.set_gpio_output_value(PIN_DC, False)
    mcp.set_gpio_output_value(PIN_PWR, False)
    mcp.gpio_update()
    # clear_mcp2210_gpio_status(mcp)
    # mcp.gpio_update()


mcp = Mcp2210(serial_number="0002193217",
              immediate_gpio_update=False)

print("Start")
init_mcp2210_for_paper(mcp)
print("Power ON")
power_on(mcp)
print("Reset")
reset(mcp)
print("Init Paper")
init_paper(mcp)

print("Fill")
fill_all_pixels(mcp, 0xaf) # 10(Yellow) 10(Yellow) 11(Red) 11(Red)
print("Refresh")
refresh_display(mcp)

print("Sleep")
time.sleep(5)

print("Fill")
fill_all_pixels(mcp)
print("Refresh")
refresh_display(mcp)

print("Shutdown")
shutdown_paper(mcp)

print("End")

黄色と赤のストライプが一面に表示されます。5秒待ってから、白一色に戻してから終了します。

画像を表示してみる

Pythonで画像を扱うにはpillowというライブラリを使うと良いみたいですね。

pip install pillow

ソースコードの方は次のようになります。既に他の電子ペーパーモジュール用に改良した後のものを3.5インチ用に一部戻したので過度に複雑になっています。電子ペーパーモジュールとの通信部分と電子ペーパーモジュールそのものに対する処理をクラスで分けてあります。

import time
import logging
import mcp2210
from abc import ABC, abstractmethod
from typing import Sequence
from PIL import Image, ImagePalette

logger = logging.getLogger(__name__)
logger.setLevel(logging.DEBUG)

class EPaperBridgeMCP2210:
    """MCP2210を使用したブリッジ"""

    def __init__(self, serial_number: str):
        self._pin_dc = 2
        self._pin_com_led = 3
        self._pin_busy = 6
        self._pin_rst = 0
        self._pin_pwr = 4
        self._cs_pin = 1
        self._reset_wait_times = (0.2, 0.002, 0.2)
        self._mcp = mcp2210.Mcp2210(serial_number, immediate_gpio_update=False)
        # self._mcp._spi_settings.bit_rate = 12000000
        self.init_mcp2210_for_paper()

    def init_mcp2210_for_paper(self):
        self._mcp.configure_spi_timing(chip_select_to_data_delay=0,
                                       last_data_byte_to_cs=0,
                                       delay_between_bytes=0)

        self.clear_mcp2210_gpio_status()
        self._mcp.set_gpio_designation(self._cs_pin, mcp2210.Mcp2210GpioDesignation.CHIP_SELECT)
        self._mcp.set_gpio_direction(self._pin_busy, mcp2210.Mcp2210GpioDirection.INPUT)
        self._mcp.set_gpio_output_value(self._pin_rst, True)
        self._mcp.set_gpio_output_value(self._pin_com_led, True)
        self._mcp.gpio_update()

    def clear_mcp2210_gpio_status(self):
        for pin_number in range(9):
            self._mcp.set_gpio_designation(pin_number, mcp2210.Mcp2210GpioDesignation.GPIO)
            self._mcp.set_gpio_direction(pin_number, mcp2210.Mcp2210GpioDirection.OUTPUT)
            self._mcp.set_gpio_output_value(pin_number, False)

    def power_on(self):
        self._mcp.set_gpio_output_value(self._pin_pwr, True)
        self._mcp.gpio_update()
        time.sleep(0.2)

    def reset(self):
        reset_value = True
        for wait_time in self._reset_wait_times:
            self._mcp.set_gpio_output_value(self._pin_rst, reset_value)
            self._mcp.gpio_update()
            reset_value = not reset_value
            time.sleep(wait_time)
        self.wait_until_not_busy()

    def power_off(self):
        self._mcp.set_gpio_output_value(self._pin_rst, False)
        self._mcp.set_gpio_output_value(self._pin_dc, False)
        self._mcp.set_gpio_output_value(self._pin_pwr, False)
        self._mcp.gpio_update()
        # self.clear_mcp2210_gpio_status(mcp)
        # self._mcp.gpio_update()

    def wait_until_not_busy(self):
        start_time = time.time()
        logger.debug("Start waiting (busy=%s)" % self._mcp.get_gpio_value(self._pin_busy))
        # time.sleep(0.1)
        while(self._mcp.get_gpio_value(self._pin_busy) == False):
            time.sleep(0.005)
        logger.debug("End waiting (wait time=%s seconds)" % (time.time() - start_time))

    def send_command_code(self, command_code: int):
        self._mcp.set_gpio_output_value(self._pin_dc, False)
        self._mcp.gpio_update()
        self._mcp.spi_exchange(bytes([command_code]), self._cs_pin)

    def send_data_bytes(self, data_bytes: bytes):
        self._mcp.set_gpio_output_value(self._pin_dc, True)
        self._mcp.gpio_update()
        self._mcp.spi_exchange(data_bytes, self._cs_pin)

    def send_data(self, data: int | Sequence[int]):
        if isinstance(data, int):
            self.send_data_bytes(bytes([data]))
        elif isinstance(data, bytes):
            self.send_data_bytes(data)
        else:
            self.send_data_bytes(bytes(data))

    def send_command(self, command_code: int, *params: int | Sequence[int]):
        self.send_command_code(command_code)
        for p in params:
            self.send_data(p)

class EPaperDisplayWaveshare(ABC):
    "Waveshare製EPaperディスプレイモジュール(HAT)の基底クラス"
    def __init__(self,
                 bridge: EPaperBridgeMCP2210,
                 width: int, height: int, bits_per_pixel: int,
                 palette: bytes, default_pixel_value: int,
                 all_driver_chip_numbers: Sequence[int]):
        self._bridge = bridge
        self._width = width
        self._height = height
        self._bits_per_pixel = bits_per_pixel
        self._palette = palette
        self._default_pixel_value = default_pixel_value
        self._all_driver_chip_numbers = all_driver_chip_numbers
        self.init()

    # Initialization

    def init(self):
        try:
            self._bridge.power_on()
            self._bridge.reset()
            self.init_driver_chips()
        except Exception:
            self._bridge.power_off()
            raise

    @abstractmethod
    def init_driver_chips(self):
        ...

    # Shutdown

    def shutdown(self):
        try:
            self.shutdown_driver_chips()
        finally:
            self._bridge.power_off()

    def shutdown_driver_chips(self):
        # self._bridge.wait_until_not_busy() ?
        # 07:Deep Sleep Command
        self._bridge.send_command(0x07, 0xA5)
        time.sleep(2)

    # Panel Power Control

    def power_on_panel(self):
        # 04:Power ON Command
        self._bridge.send_command(0x04)
        self._bridge.wait_until_not_busy()

    def power_off_panel(self):
        # 02:Power OFF Command
        self._bridge.send_command(0x02, 0x00)
        self._bridge.wait_until_not_busy()

    # Frame

    @abstractmethod
    def set_frame_bytes(self, frame_bytes: bytes):
        ...

    @property
    def width(self) -> int:
        """パネル全体の水平方向のピクセル数です。"""
        return self._width

    @property
    def height(self) -> int:
        """パネル全体の垂直方向のピクセル数です。"""
        return self._height

    @property
    def default_pixel_value(self) -> int:
        """デフォルトのピクセル値です。基本的に「白」を意味する値です。"""
        return self._default_pixel_value

    @property
    def frame_bits_per_pixel(self) -> int:
        """ピクセルあたりのビット数です。基本的に8以下の値です。"""
        return self._bits_per_pixel

    @property
    def frame_pixels_per_byte(self) -> int:
        """1バイトあたりのピクセル数です。"""
        return 8 // self._bits_per_pixel

    def make_filled_frame_byte(self, pixel_value: int):
        """1バイトの中を指定されたpixel_valueで満たしたものを返します。"""
        bpp = self.frame_bits_per_pixel
        pixel_value = pixel_value & ((1 << bpp) - 1)
        frame_byte = 0
        bitpos = 8 - bpp
        while bitpos >= 0:
            frame_byte |= pixel_value << bitpos
            bitpos = bitpos - bpp
        return frame_byte

    @property
    def frame_line_nbytes(self) -> int:
        """フレームバッファの1行のバイト数を返します。"""
        return ((self._width + self.frame_pixels_per_byte - 1) //
                self.frame_pixels_per_byte)

    @property
    def frame_nbytes(self) -> int:
        """フレームバッファのバイト数を返します。"""
        return self.frame_line_nbytes * self.height

    def fill_frame_with_byte(self, frame_byte: int):
        """フレームの全バイトをframe_byteで満たします。"""
        self.set_frame_bytes(bytes([frame_byte]) * self.frame_nbytes)

    def fill_frame(self, pixel_value: int):
        """フレームの全ピクセルをpixel_valueで塗りつぶします。"""
        self.fill_frame_with_byte(self.make_filled_frame_byte(pixel_value))

    def clear_frame(self):
        """フレームの全ピクセルを白一色で塗りつぶします。"""
        self.fill_frame(self.default_pixel_value)


    def frame_image_palette(self) -> ImagePalette.ImagePalette:
        return ImagePalette.ImagePalette("RGB", self._palette)

    def convert_image_to_frame_bytes(self, image: Image.Image) -> bytes:
        """imageをselfの仕様に合わせたフレームバイト列へ変換します。"""
        palette_image = Image.new("P", (1, 1))
        palette_image.putpalette(self.frame_image_palette())

        quantized_image = image.convert("RGB").quantize(palette=palette_image)

        src_bytes = quantized_image.getdata()
        src_w, src_h = quantized_image.size

        return bytes(EPaperDisplayWaveshare.convert_pimage_bytes_to_frame_bytes(
            src_bytes, src_w, 0, 0, src_w, src_h,
            self.width, self.height, self.frame_bits_per_pixel))

    @staticmethod
    def convert_pimage_bytes_to_frame_bytes(
            src_bytes,
            src_pitch: int,
            src_x: int, src_y: int, src_w: int, src_h: int,
            dst_w: int, dst_h: int, dst_bits_per_pixel: int) -> bytearray:
        scan_w = min(src_w, dst_w)
        scan_h = min(src_h, dst_h)
        dst_line_nbytes = (dst_w // (8 // dst_bits_per_pixel))
        dst_pitch = dst_line_nbytes
        dst_bytes = bytearray(dst_line_nbytes * dst_h)
        for y in range(scan_h):
            src = src_pitch * (src_y + y) + src_x
            dst = dst_pitch * y
            dst_bitpos = 8
            dst_byte = 0
            dst_bitmask = (1 << dst_bits_per_pixel) - 1
            for x in range(scan_w):
                dst_bitpos -= dst_bits_per_pixel
                dst_byte |= ((int(src_bytes[src + x]) & dst_bitmask)
                             << dst_bitpos)
                if dst_bitpos < dst_bits_per_pixel:
                    dst_bytes[dst] = dst_byte
                    dst = dst + 1
                    dst_bitpos = 8
                    dst_byte = 0
            if dst_bitpos < 8:
                dst_bytes[dst] = dst_byte
        return dst_bytes

    # Panel Update

    def update_panel(self):
        """現在のフレームをディスプレイの表示に反映します。"""
        # 04:Power ON Command
        self.power_on_panel()
        # 12:Display Refresh Command
        self._bridge.send_command(0x12, 0x00)
        self._bridge.wait_until_not_busy()
        # 02:Power OFF Command
        self.power_off_panel()

    def show_image(self, image: Image.Image):
        """imageを表示します。"""
        self.set_frame_bytes(self.convert_image_to_frame_bytes(image))
        print("Refresh")
        self.update_panel()


class EPaperDisplayWaveshare3in5G(EPaperDisplayWaveshare):
    """Waveshare 3.5インチ(G)"""

    def __init__(self, bridge):
        super().__init__(
            bridge = bridge,
            width = 184,
            height = 384,
            bits_per_pixel = 2,
            palette = bytes((0,0,0)+(255,255,255)+(255,255,0)+(255,0,0)+
                            (0,0,0)*252),
            default_pixel_value = 1,
            all_driver_chip_numbers = [0]
        )

    def init_driver_chips(self):
        self._bridge.send_command(0x66, 0x49, 0x55, 0x13, 0x5D, 0x05, 0x10)
        self._bridge.send_command(0x4D, 0x78)
        self._bridge.send_command(0x00, 0x0F, 0x29)
        self._bridge.send_command(0x01, 0x07, 0x00)
        self._bridge.send_command(0x03, 0x10, 0x54, 0x44)
        self._bridge.send_command(0x06, 0x0F, 0x0A, 0x2F, 0x25, 0x22, 0x2E, 0x21)
        self._bridge.send_command(0x50, 0x37)
        self._bridge.send_command(0x60, 0x02, 0x02)
        self._bridge.send_command(0x61,
                                  self._width>>8, self._width&255,
                                  self._height>>8, self._height&255)
        self._bridge.send_command(0xE7, 0x1C)
        self._bridge.send_command(0xE3, 0x22)
        self._bridge.send_command(0xB6, 0x6F)
        self._bridge.send_command(0xB4, 0xD0)
        self._bridge.send_command(0xE9, 0x01)
        self._bridge.send_command(0x30, 0x08)

    # Frame

    def set_frame_bytes(self, frame_bytes: bytes):
        """frame_bytesを電子ペーパーに転送します(まだ表示しない)。"""
        self._bridge.send_command(0x10, frame_bytes)


print("Load image")
#im = Image.open("3in5g.bmp")
im = Image.open("PXL_20251008_015247023~2.jpg")
print("Rotate")
im = im.rotate(-90, expand=True)

print("Create EPaper")
epaper = EPaperDisplayWaveshare3in5G(
    EPaperBridgeMCP2210(serial_number="0002193217"))

try:
    print("Resize")
    im = im.resize((epaper.width, epaper.height))
    print("Show Image")
    epaper.show_image(im)

    print("Sleep")
    time.sleep(5)

    # print("Fill")
    # epaper.fill_frame_with_byte(0xaf)
    # print("Refresh")
    # epaper.update_panel()

    # print("Sleep")
    # time.sleep(5)

    print("Fill")
    epaper.clear_frame()
    print("Refresh")
    epaper.update_panel()

finally:
    print("Shutdown")
    epaper.shutdown()

print("End")

パネルの解像度は184×384です(縦長)。1ピクセルは2ビットでMSBから詰めていき1バイトで4ピクセル入ります。つまり1行は184/4=46バイトです。全体では46*384=17664バイトとなります。これをコマンド0x10のパラメータとして送ってやり、その後パネルの電源(0x04)をON→リフレッシュ(0x12)→パネルの電源OFF(0x02)とすると実際の画面が更新されます。なお、転送に3秒、リフレッシュに15秒くらいかかります。

というわけで表示させてみたのがこちら。

紅葉の写真を表示した例
図3: 紅葉の写真を表示した例

これは先日撮った紅葉の写真なのですが、黒白黄赤の四色しか無いのに案外色が再現できています。さすがに青空は灰色ですけど。

ちなみに元の写真はこんな感じです。

元の写真
図4: 元の写真

Pillowにはディザリングで減色する機能があるのでそれを利用しています。

テキストを含む画像を作成して表示させてみたのが次の例。

文字を表示した例
図5: 文字を表示した例

結構シャープに表示できています。

でも3.5インチだと大したものは表示できませんね。

……となると、もう少し大きいパネルが欲しくなってきますが、それはまた次回

2025-11-05 ,

Org Agendaに天気・日の出日の入・月の状態を表示する

org-modeのagendaについてはこれまでに色々な設定をしてきました。

関連記事:

特に日付に対する付加情報として、毎日の天気、日の出時刻、日の入り時刻、月相、月出、月没等も表示させるようにしてきました(diary-sexpで)。

ただ、これらの日付に対する付加的な情報を、情報ごとに1行ずつ表示していると肝心の予定が見づらくなってしまいます。なので私はこれらの情報は1日1行にまとめて表示するようにもしてきました。

今日はそれをさらに一歩進めて、付加的な情報は日付の右側に小さく表示させてみようと思います。

最終的な見た目は次のようになりました。

2025-11-05-org-agenda.png

これならあまり邪魔にならないでしょう。

設定箇所が散乱していて大変でしたが、関連しそうな所を次のようにまとめてみました。足りないところがあったらすみません。

;;; my-org-agenda.el ---                            -*- lexical-binding: t; -*-
(require 'org-agenda)
(require 'calendar)
(require 'solar)
(require 'lunar)
(require 'japanese-holidays) ;; https://github.com/emacs-jp/japanese-holidays
(require 'jma-forecast) ;; https://github.com/misohena/el-jma
(require 'moonrise) ;; https://github.com/misohena/moonrise-el

;;;; 雑多な設定(関係ないのも含まれているかも)

;; moonrise-el用
(setq moonrise-day-events-format-org-agenda
      '(rise set
             (time :hour 12 :preceding t :display-time nil
                   :display-moon-phase t))
      moonrise-org-agenda-event-separator ""
      moonrise-org-agenda-use-cache t
      moonrise-point-event-format
      '((point-name :separator " ") (time :separator "")
        (moon-age :separator " ") (moon-phase :separator ""))
      moonrise-point-name-alist '((rise . "月出") (set . "月没") (meridian . "南中")))

;; el-jma用
(setq jma-weather-code-image-default-height 22
      jma-forecast-location-amedas "44132" ;; 東京(AMEDAS)
      jma-forecast-location-class10 "130010" ;; 東京地方
      jma-forecast-location-office "130000" ;; 東京都
      jma-forecast-location-week-amedas "44132" ;; 東京(AMEDAS)(週間予報)
      jma-forecast-location-week-area "130010") ;; 東京地方(週間予報)

;; solar.el用
(setq solar-n-hemi-seasons '("春分" "夏至" "秋分" "冬至"))

(setq calendar-latitude 35.0000 ;; 緯度
      calendar-longitude 139.0000) ;; 経度

;; org-modern用 (必要に応じて)
;; (add-hook 'org-agenda-finalize-hook #'org-modern-agenda)


;;; org-agenda用の設定

(setq
 org-agenda-custom-commands
 '(("a" "Default"
    ((agenda "" ((org-agenda-overriding-header "TODO"))))
    ((org-agenda-export-html-style
      "<link rel=\"stylesheet\" type=\"text/css\" href=\"agenda.css\">")
     (org-agenda-use-time-grid nil))
    ("~/my-org-agenda-html/agenda.html")))
 org-agenda-deadline-leaders '("DL" "DL%dd:" "DL-%dd:")
 org-agenda-files '("~/my-org-files/todo.org")
 org-agenda-include-diary t
 org-agenda-prefix-format
 '((agenda . " %i %?-12t%? s") (todo . " %i %-8:c")
   (tags . " %i %-8:c") (search . " %i %-8:c"))
 org-agenda-scheduled-leaders '("" "-%dd:")
 org-agenda-search-headline-for-time nil
 org-agenda-sort-notime-is-late nil
 org-agenda-span 31
 org-agenda-tags-column 0
 org-agenda-time-grid
 '((daily today require-timed) (800 1000 1200 1400 1600 1800 2000)
   " ·····" "────────────"))


;;;; 長い見出しの折り返し後をインデント

;; https://misohena.jp/blog/2022-10-30-org-agenda-wrap-prefix.html

(defvar my-org-agenda-format-item-prefix "") ;;formatterが返した値を取っておくための変数。
(defun my-org-agenda-format-item (orig-fun &rest args)
  ;; 元のorg-agenda-format-itemを呼び出す前に
  ;; org-prefix-format-compiledを一時的に書き替える。
  (let* ((org-prefix-format-compiled
          (list
           (car org-prefix-format-compiled)
           ;; formatterを書き替えてしまう。
           ;; 結果を my-org-agenda-format-item-prefix に書き込む関数に。
           (list 'setq
                 'my-org-agenda-format-item-prefix
                 (cadr org-prefix-format-compiled))))
         ;; 元のorg-agenda-format-itemを呼び出す。
         (rv (apply orig-fun args)))
    ;; 戻り値にwrap-prefixテキストプロパティを追加する。
    ;; インデントの深さはformatterが返した文字列(prefix)の長さとする。
    (put-text-property
     0 (length rv) 'wrap-prefix
     (make-string (length my-org-agenda-format-item-prefix) ? )
     rv)
    rv))

(advice-add #'org-agenda-format-item :around #'my-org-agenda-format-item)


;;;; ブロック区切りを一行に

;; ブロック区切りを制御するには `org-agenda-block-separator' を使用するが、
;; これはnilにすると空行が無くなってしまうし、文字や文字列を設定すると必ず
;; 2つの\nが挿入されてしまう。""を指定すると空行が2つ挿入されてしまう。
;; この処理は `org-agenda-prepare' で行っているので、そのafterアドバイスで
;; \nを一つだけ挿入する。

(setq org-agenda-block-separator nil) ;; 2行追加しない。

(defun my-org-agenda-prepare-after (&rest _args)
  (when (and (not (org-agenda-use-sticky-p))
             org-agenda-multi
             (> (point) 1) ;; (not (bobp))ではダメ。narrowingされているから。
             (not org-agenda-compact-blocks))
    ;; 1行のみ追加してそれ以降をナローイング
    (insert "\n")
    (narrow-to-region (point) (point-max))))

(advice-add #'org-agenda-prepare :after #'my-org-agenda-prepare-after)


;;;; 日付の形式

(defconst my-org-agenda-dow '(?日 ?月 ?火 ?水 ?木 ?金 ?土))

(defun my-org-agenda-format-date--date (date)
  (let* ((year (caddr date))
         (month (car date))
         (day (cadr date))
         (dow (elt my-org-agenda-dow (calendar-day-of-week date)))
         (today (calendar-gregorian-from-absolute (org-today)))
         (today-year (caddr today))
         (today-month (car today)))
    (concat
     (if (equal year today-year)
         (if (equal month today-month)
             (format "%d" day)
           (format "%2d/%02d" month day))
       (format "%d/%02d/%02d" year month day))
     ;; " "
     (propertize (format "%c" dow) 'my-org-agenda-dow t))))

(defun my-org-agenda-format-date (date)
  (concat
   ;; 日付の上に空白
   (propertize "​" 'display '(space :width (1) :height 1.75 :ascent 100))
   ;; 日付
   (my-org-agenda-format-date--date date)))

(setq org-agenda-format-date #'my-org-agenda-format-date)


;;;; 日付の色

;; https://misohena.jp/blog/2021-08-29-colorize-saturday-and-japanese-holidays-in-org-agenda.html

(defface my-org-agenda-date-saturday
  '((t (:inherit org-agenda-date :foreground "#0bf" :weight bold)))
  "Face used in agenda for saturday."
  :group 'org-faces)

(defface my-org-agenda-dow-saturday
  '((t (:inherit my-org-agenda-date-saturday :height 0.8)))
  "Face used in agenda for saturday."
  :group 'org-faces)

(defface my-org-agenda-dow-weekend
  '((t (:inherit org-agenda-date-weekend :height 0.8)))
  "Face used in agenda for saturday."
  :group 'org-faces)

(defface my-org-agenda-dow
  '((t (:inherit org-agenda-date :height 0.8)))
  "Face used in agenda for saturday."
  :group 'org-faces)

(defun my-org-agenda-day-face (date)
  (let ((face (cond
               ;; 土曜日
               ((= (calendar-day-of-week date) 6)
                'my-org-agenda-date-saturday)
               ;; 日曜日か日本の祝日
               ((or (= (calendar-day-of-week date) 0)
                    (let ((calendar-holidays japanese-holidays))
                      (calendar-check-holidays date)))
                'org-agenda-date-weekend)
               ;; 普通の日
               (t 'org-agenda-date))))
    ;; 今日は色を反転
    ;;(if (org-agenda-today-p date) (list :inherit face :inverse-video t) face)
    face))

(setq org-agenda-day-face-function #'my-org-agenda-day-face)


;; 数字部分と曜日部分を分けて調整できるようにする

(defun my-org-agenda-fontify-dow ()
  ;; 曜日部分を独立したfaceにします。
  ;; `org-agenda-format-date'や`org-agenda-day-face-function'が呼び出され
  ;; るタイミングで行ってもダメなので、Agendaが完成した後にfaceを書き替えます。
  ;; See: `org-agenda-list'
  (let ((inhibit-read-only t)
        match)
    (save-excursion
      (goto-char (point-min))
      (while (setq match (text-property-search-forward 'my-org-agenda-dow t t))
        (put-text-property
         (prop-match-beginning match)
         (prop-match-end match)
         'face
         (pcase (get-text-property (1- (point)) 'face)
           ('org-agenda-date 'my-org-agenda-dow)
           ('org-agenda-date-weekend 'my-org-agenda-dow-weekend)
           ('my-org-agenda-date-saturday 'my-org-agenda-dow-saturday)))))))

(add-hook 'org-agenda-finalize-hook 'my-org-agenda-fontify-dow)


;;;; 日付付加情報

(defun my-org-agenda-sunrise-sunset ()
  ;; Return "<日の出><日の入>"
  (let ((times (solar-sunrise-sunset org-agenda-current-date)))
    (mapconcat
     #'identity
     (delq
      nil
      (list
       (when (car times) (apply #'solar-time-string (car times)))
       (when (and (car times) (cadr times)) "~")
       (when (cadr times) (apply #'solar-time-string (cadr times)))))
     "")))

(defun my-org-agenda-sun-and-moon ()
  (mapconcat #'identity
             (delq nil
                   (list
                    ;; "<日の出><日の入>"
                    (my-org-agenda-sunrise-sunset)
                    ;; "<12時月相><月出没>*"
                    (moonrise-org-agenda)
                    ;; "<四朔望>"
                    (cdr (diary-lunar-phases))))
             " "))

(defun my-org-agenda-weather ()
  (ignore-errors
    (jma-diary-weathers
     ;; 東京都 東京地方 東京
     "130000" "130010" "44132" "130010" "44132"
     ;; "<天気><降水確率><最低気温><最高気温>"
     "{{{weather-image:%s}}}{{{pop:%s%%}}}\
{{{temp-min: %s~}}}{{{temp-max:%s℃}}}")))

(defun my-org-agenda-nature-environment (date-arg)
  ;; 指定された日付(DATE-ARG)の自然環境情報文字列を返す。
  (with-no-warnings (defvar date))
  (let ((date date-arg)
        (org-agenda-current-date date-arg))
    (mapconcat
     #'identity
     (delq nil
           (list
            ;; 天気
            (my-org-agenda-weather)
            ;; 日の出・日の入
            (my-org-agenda-sunrise-sunset)
            ;; 12時月相・月出没
            (let ((moonrise-point-name-alist
                   '((rise . "↑")
                     (set . "↓")
                     (meridian . "中"))))
              (moonrise-org-agenda))
            ;; 四朔望
            (cdr (diary-lunar-phases))))
     " ")))

(defun my-org-agenda-day-info (date)
  ;; 日付DATEの付加情報文字列を返す。
  (propertize
   (concat
    " "
    (my-org-agenda-nature-environment date))
   'my-org-agenda-date-info t
   'face 'my-org-agenda-date-info))

(defface my-org-agenda-date-info
  '((t (:foreground "#999" :height 0.8)))
  "Face used in agenda for date info."
  :group 'org-faces)

(defun my-org-agenda-date-info-display-p ()
  ;; 特定の場所でのみ日付付加情報を表示するなら、ここで何らかの条件で判定せよ
  ;; (equal
  ;;  (get-text-property (point) 'org-series-cmd)
  ;;  '(agenda "" ((org-agenda-overriding-header "TODO"))))
  t)

(defcustom my-org-agenda-date-info-display-p
  #'my-org-agenda-date-info-display-p
  "日付付加情報を表示する条件。
  nil = 常に表示しない
  t = 常に表示する
  関数 = Agendaバッファの日付部分末尾で呼び出され、非nilを返したら表示"
  :type '(choice boolean function)
  :group 'org)

(defun my-org-agenda-update-date-info-face ()
  ;; 日付付加情報をAgendaバッファに挿入する。
  (when my-org-agenda-date-info-display-p
    (let ((inhibit-read-only t))
      (save-excursion
        (goto-char (point-min))
        (while (text-property-search-forward 'org-agenda-date-header)
          (when-let* ((day (get-text-property (1- (point)) 'day))
                      (date (calendar-gregorian-from-absolute day)))
            (when (or (eq my-org-agenda-date-info-display-p t)
                      (and (functionp my-org-agenda-date-info-display-p)
                           (funcall my-org-agenda-date-info-display-p)))
              (insert (my-org-agenda-day-info date)))))))))

(add-hook 'org-agenda-finalize-hook 'my-org-agenda-update-date-info-face)

org-agendaの出力を細かくカスタマイズするにはorg-agenda-finalize-hookを使うのが最も強力なようです。これはorg-agendaの出力が一通り終わった後に呼び出されるフックなので、細かいところも全て後から書き替える事が出来ます。ただし、バッファ全体をスキャンする必要が出てくるので多少効率は落ちます。

出力関数の一部の挙動をピンポイントで修正しようと思っても、それはほとんどの場合困難です。出力関数(org-agenda-list)は205行にもなる大きな関数ですし、処理が適切な粒度で関数化されていないので割り込む余地がありません。

というわけで上のコードでは、org-agenda-finalize-hookmy-org-agenda-update-date-info-facemy-org-agenda-fontify-dow といったフックを追加して付加情報の挿入や曜日部分のface分離を行っています。

その他細かいところを調整するのに苦労しました。

それでも最終的に調整できてしまうのがEmacsの良い所ではあります。

2025-07-27

VerticoとCorfuをタッチスクリーンで操作できるようにする

私はEmacsの補完インタフェースにVerticoとCorfuを使用していますが、Android版のEmacsを使っていると補完候補の一覧をタップで選択できないことにフラストレーションを感じます。同様にスワイプによるスクロールも出来ません。

というわけで、何とかしてみました。

Vertico用のコード:

;;; my-vertico-touch.el ---                          -*- lexical-binding: t; -*-

;; 使い方:
;; (with-eval-after-load 'vertico
;;   (require 'my-vertico-touch)
;;   (my-vertico-touch-setup)

(require 'vertico)
(require 'vertico-mouse)

(defconst my-vertico-touch-tap-threshold 4)

(defun my-vertico-touchscreen-begin (begin-event)
  (interactive "e")
  (let* ((begin-posn (cdadr begin-event))
         (begin-xy (posn-x-y begin-posn))
         (begin-window (posn-window begin-posn))
         (moved nil))
    (with-selected-window begin-window
      (let ((begin-scroll-pos vertico--scroll))
        (while
            (let ((ev (read-event)))
              (pcase (car-safe ev)
                ('touchscreen-update
                 (let* ((update-xy (touch-screen-relative-xy (cdaadr ev)
                                                             begin-window))
                        (dx (- (car update-xy) (car begin-xy)))
                        (dy (- (cdr update-xy) (cdr begin-xy))))
                   (when (and (not moved)
                              (>= (+ (* dx dx) (* dy dy))
                                  (* my-vertico-touch-tap-threshold
                                     my-vertico-touch-tap-threshold)))
                     (setq moved t))
                   (when moved
                     (let* ((dline (/ dy (default-line-height)))
                            (new-scroll-pos (- begin-scroll-pos dline)))
                       (cond
                        ((< new-scroll-pos vertico--scroll)
                         (vertico--goto (+ new-scroll-pos vertico-scroll-margin)))
                        ((> new-scroll-pos vertico--scroll)
                         (vertico--goto (+ new-scroll-pos vertico-count
                                           (- vertico-scroll-margin))))))
                     (vertico--exhibit)))
                 t)
                ('touchscreen-end
                 (unless moved
                   (vertico--goto (vertico-mouse--index begin-event))
                   (vertico-exit))
                 nil))))))))

(defun my-vertico-touch-setup ()
  (interactive)
  (vertico-mouse-mode)
  (define-key vertico-mouse-map (kbd "<touchscreen-begin>")
              #'my-vertico-touchscreen-begin))

(defun my-vertico-touch-teardown ()
  (interactive)
  (define-key vertico-mouse-map (kbd "<touchscreen-begin>")
              #'my-vertico-touchscreen-begin
              t))

(provide 'my-vertico-touch)

Corfu用のコード:

;;; my-corfu-touch.el ---                            -*- lexical-binding: t; -*-

;; 使い方:
;; (with-eval-after-load "corfu"
;;   (require 'my-corfu-touch)
;;   (my-corfu-touch-setup))

(require 'corfu)

;;;; Frame Handling

(defun my-corfu-defocus-child-frame ()
  "corfu用の子フレームからフォーカスを外す。"
  (when (eq (selected-frame) corfu--frame)
    (when-let* ((parent (frame-parent)))
      (select-frame parent))))

(defun my-corfu-handle-switch-frame-p ()
  "フレームの変更処理中なら非nilを返す。
`this-command'と`last-input-event'によって判定される。

`this-command'が`handle-switch-frame'であり、かつ、`last-input-event'が
corfu用の子フレームに対する`switch-frame'イベントであれば、非nilを返し、
そうでなければnilを返す。"
  (and (eq this-command 'handle-switch-frame)
       (eq (car-safe last-input-event) 'switch-frame)
       (eq (cadr last-input-event) corfu--frame)))

;; 子フレームがクリック/タップされたとき、`switch-frame'イベントが発生
;; し`handle-switch-frame'コマンドが実行される。また、その前後でwindow
;; 変更を通知するフックも呼び出される(クリックかタップかによって微妙に
;; 順番は変わる?)。
;;
;; それらのタイミングのどこかでフォーカスの変更が行われるので、
;; `my-corfu-defocus-child-frame'を呼び出して元の親フレームがフォーカス
;; されている状態を維持する必要がある(`switch-frame'の効果を打ち消す)。
;;
;; また、`handle-switch-frame'コマンドによってcorfuが終了してしまうこ
;; とがあるので、それも防止する必要がある。基本的にcorfuの候補表示フレー
;; ム(`corfu--frame')に対する`handle-switch-frame'は無視した方が良い。

(defun my-corfu--post-command:around (old-fun &rest args)
  (my-corfu-defocus-child-frame)

  (unless (my-corfu-handle-switch-frame-p)
    (apply old-fun args)))

(defun my-corfu--prepare:around (old-fun &rest args) ;; pre-command-hook
  (unless (my-corfu-handle-switch-frame-p)
    (apply old-fun args)))

(defun my-corfu--window-change:around (old-fun &rest args)
  ;; 注意: タッチイベントの時はhandle-switch-frameよりも先にここに来る。
  ;;       クリックの時は先にhandle-switch-frameが発生するのでこれは不要。
  (my-corfu-defocus-child-frame)
  (apply old-fun args))

;;;; Candidate List

(defun my-corfu-select (index)
  (corfu--goto index)
  (corfu-insert))

(defun my-corfu-posn-line-number (posn)
  (with-current-buffer (window-buffer (posn-window posn))
    (line-number-at-pos (posn-point posn) t)))

(defun my-corfu-posn-index (posn)
  (+ corfu--scroll (my-corfu-posn-line-number posn) -1))

(defun my-corfu-select-clicked (event)
  (interactive "e")
  (my-corfu-select (my-corfu-posn-index (event-start event))))

(defun my-corfu-set-scroll-pos (new-scroll-pos)
  (cond
   ((< new-scroll-pos corfu--scroll)
    (corfu--goto (+ new-scroll-pos corfu-scroll-margin)))
   ((> new-scroll-pos corfu--scroll)
    (corfu--goto (+ new-scroll-pos corfu-count
                    (- corfu-scroll-margin))))))

;;;; Mouse / Touch Event Handlers

(defun my-corfu-on-mouse-1 (event)
  (interactive "e")
  (my-corfu-select-clicked event))

(defconst my-corfu-touch-tap-threshold 4)

(defun my-corfu-on-touchscreen-begin (begin-event)
  (interactive "e")
  (let* ((begin-posn (cdadr begin-event))
         (begin-window (posn-window begin-posn))
         (begin-xy (posn-x-y begin-posn))
         (moved nil))
    (with-selected-window begin-window
      (let ((begin-scroll-pos corfu--scroll)
            (echo-keystrokes 0))
        (while
            (let ((ev (read-event)))
              (pcase (car-safe ev)
                ('touchscreen-update
                 (let* ((update-xy (touch-screen-relative-xy (cdaadr ev) begin-window))
                        (dx (- (car update-xy) (car begin-xy)))
                        (dy (- (cdr update-xy) (cdr begin-xy))))
                   (when (and (not moved)
                              (>= (+ (* dx dx) (* dy dy))
                                  (* my-corfu-touch-tap-threshold
                                     my-corfu-touch-tap-threshold)))
                     (setq moved t))
                   (when moved
                     (let* ((dline (/ dy (default-line-height)))
                            (new-scroll-pos (- begin-scroll-pos dline)))
                       (my-corfu-set-scroll-pos new-scroll-pos))
                     (corfu--exhibit)))
                 t)
                ('touchscreen-end
                 (unless moved
                   (my-corfu-select (my-corfu-posn-index (cdadr begin-event))))
                 nil))))))))

;;;; Setup

(defun my-corfu-touch-setup ()
  (interactive)

  ;; pre-command-hook、post-command-hook、ウィンドウ切り替え時の処理を修正する。
  (advice-add 'corfu--post-command :around 'my-corfu--post-command:around)
  (advice-add 'corfu--prepare :around 'my-corfu--prepare:around)
  (advice-add 'corfu--window-change :around 'my-corfu--window-change:around)

  ;; マウスを無視するためのキーマップにマウスやタッチのイベントハンドラを
  ;; 登録してしまう。
  (push 'my-corfu-on-mouse-1 corfu-continue-commands)
  (push 'my-corfu-on-touchscreen-begin corfu-continue-commands)
  (define-key corfu--mouse-ignore-map
              [mouse-1] #'my-corfu-on-mouse-1)
  (define-key corfu--mouse-ignore-map
              [touchscreen-begin] #'my-corfu-on-touchscreen-begin)

  ;; 子フレームにフォーカスが当たるようにする。
  ;; そうしないとイベントが起きないので。
  (setf (alist-get 'no-accept-focus corfu--frame-parameters) nil))

(provide 'my-corfu-touch)

VerticoもCorfuも同じ作者によるものなので構造は似ています。

どちらもマウスやタッチイベントは完全に無視するように作られているので、まずはそれを解除する必要があります。

Verticoの方はvertico-mouse-modeというのが付属しているので、それを参考にしてタッチイベントへの対応を追加しました。

Corfuの方は子フレームを使っているので、改善の難易度が上がります。タッチした瞬間に子フレームにフォーカスが移動し、当然カレントバッファも変わってしまいます。そのため上のコードではフォーカスを親フレームに戻す処理を入れています。そういった遷移イベントによってCorfuが終了してしまうことも防止する必要がありました。一応マウスクリックにも対応させてみましたが、ホイールへの対応はうまく出来ませんでした。フォーカスが当たっていない別フレームでホイールを回しても、ホイールイベントは発生しないようです(MS-Windowsでの使用時)。

これらのコードはMS-Windowsのタッチパネル搭載ノートPCとAndroidの両方でテストして動作することを確認しました。

設定等によってはうまく動かないケースも多々あるかもしれません。

2025-07-27 , ,

Unicodeの三角形の一覧を作成する

Unicodeの三角形ってどうなってるんだっけ? と思ったので一覧を作成してみました。

Emacsでは C-x 8 RET triangle などと入力すれば(使っている補完インタフェースにもよりますが)色々出てくるわけですが、それだと4方向分が一緒くたになっているので分かりづらいのです。なので、方向を除いたベースとなる名前が一行にまとまるように表を作ってみました。

(let ((triangle-types
       ;; 次の条件を満たすUNICODE文字を列挙する。
       ;; - 名前にTRIANGLEが含まれてる
       ;; - 名前に{LEFT|UP|RIGHT|DOWN}-POINTINGが含まれている
       ;; 結果はalist ((三角形名 . ((方向名 . コード)...))...) の形にする。
       (cl-loop with triangle-types = nil
                for name being the hash-keys of (ucs-names)
                using (hash-values code) ;; ←これ書きづらいんだけど何とかならないの?
                when (and (string-match-p "TRIANGLE" name)
                          (string-match "\\`\\(.*\\)\\(LEFT\\|UP\\|RIGHT\\|DOWN\\)\\(-POINTING .*\\)\\'" name))
                do
                (let ((base-name (concat (match-string 1 name)
                                         "*" ;; 方向の部分は * に置き換える。
                                         (match-string 3 name)))
                      (dir (match-string 2 name)))
                  (setf (alist-get dir
                                   (alist-get base-name triangle-types
                                              nil nil #'equal)
                                   nil nil #'eql)
                        code))
                finally return triangle-types)))
  ;; 表の形に文字列化する。
  (let ((dir-names  '("LEFT" "UP" "RIGHT" "DOWN")))
    (concat
     "|NAME|" (mapconcat #'identity dir-names "|") "|\n"
     "|-\n"
     (cl-loop for (name . dirs) in (nreverse triangle-types)
              concat
              (concat "|" name "|"
                      (cl-loop for dir in dir-names
                               for code = (alist-get dir dirs nil nil #'equal)
                               concat (if code (format "%X %c" code code) "-")
                               concat "|")
                      "\n")))))

(例によってこの文書はorg-modeで書かれているので、コードブロックを評価すれば自動的に↓が文書中に挿入されるわけです ( :exports both :results raw replace value を指定) )

NAME LEFT UP RIGHT DOWN
BLACK *-POINTING DOUBLE TRIANGLE 23EA ⏪ 23EB ⏫ 23E9 ⏩ 23EC ⏬
BLACK *-POINTING DOUBLE TRIANGLE WITH VERTICAL BAR 23EE ⏮ - 23ED ⏭ -
BLACK *-POINTING TRIANGLE WITH DOUBLE VERTICAL BAR - - 23EF ⏯ -
BLACK MEDIUM *-POINTING TRIANGLE 23F4 ⏴ 23F6 ⏶ 23F5 ⏵ 23F7 ⏷
BLACK *-POINTING TRIANGLE 25C0 ◀ 25B2 ▲ 25B6 ▶ 25BC ▼
WHITE *-POINTING TRIANGLE 25C1 ◁ 25B3 △ 25B7 ▷ 25BD ▽
BLACK *-POINTING SMALL TRIANGLE 25C2 ◂ 25B4 ▴ 25B8 ▸ 25BE ▾
WHITE *-POINTING SMALL TRIANGLE 25C3 ◃ 25B5 ▵ 25B9 ▹ 25BF ▿
WHITE *-POINTING TRIANGLE WITH DOT - 25EC ◬ - -
*-POINTING TRIANGLE WITH LEFT HALF BLACK - 25ED ◭ - 29E8 ⧨
*-POINTING TRIANGLE WITH RIGHT HALF BLACK - 25EE ◮ - 29E9 ⧩
HEAVY WHITE *-POINTING TRIANGLE - - - 26DB ⛛
BLACK MEDIUM *-POINTING TRIANGLE CENTRED 2BC7 ⯇ 2BC5 ⯅ 2BC8 ⯈ 2BC6 ⯆
*-POINTING RED TRIANGLE - 1F53A 🔺 - 1F53B 🔻
*-POINTING SMALL RED TRIANGLE - 1F53C 🔼 - 1F53D 🔽
BLACK *-POINTING ISOSCELES RIGHT TRIANGLE 1F780 🞀 1F781 🞁 1F782 🞂 1F783 🞃

シンプルな一つの三角形で4方向揃っているものに限定すると次の7種類になります。

NAME LEFT UP RIGHT DOWN
BLACK MEDIUM *-POINTING TRIANGLE 23F4 ⏴ 23F6 ⏶ 23F5 ⏵ 23F7 ⏷
BLACK *-POINTING TRIANGLE 25C0 ◀ 25B2 ▲ 25B6 ▶ 25BC ▼
WHITE *-POINTING TRIANGLE 25C1 ◁ 25B3 △ 25B7 ▷ 25BD ▽
BLACK *-POINTING SMALL TRIANGLE 25C2 ◂ 25B4 ▴ 25B8 ▸ 25BE ▾
WHITE *-POINTING SMALL TRIANGLE 25C3 ◃ 25B5 ▵ 25B9 ▹ 25BF ▿
BLACK MEDIUM *-POINTING TRIANGLE CENTRED 2BC7 ⯇ 2BC5 ⯅ 2BC8 ⯈ 2BC6 ⯆
BLACK *-POINTING ISOSCELES RIGHT TRIANGLE 1F780 🞀 1F781 🞁 1F782 🞂 1F783 🞃

基本は「BLACK *-POINTING TRIANGLE▲」と「WHITE *-POINTING TRIANGLE△」ですね。これらはJIS X 0213にも入っています(JIS X 0208の段階では上下のみ)。私の使っている環境ではstring-width関数やchar-width関数は2を返します(設定によるかもしれません)。文書中に書くのは普通はこれですが、箇条書きの先頭(bullet)に使うには大きすぎて使いづらいです。

「BLACK MEDIUM *-POINTING TRIANGLE⏶」は少し特殊で、どうも(再生ボタン等の)メディアUIで使うことを意図しているみたい?

「BLACK *-POINTING SMALL TRIANGLE▴」と「WHITE *-POINTING SMALL TRIANGLE▵」は単純に小さいというだけ?

「BLACK MEDIUM *-POINTING TRIANGLE CENTRED⯅」は「BLACK MEDIUM *-POINTING TRIANGLE」と何が違うのか。単に中くらいのが欲しいならコレ?

「BLACK *-POINTING ISOSCELES RIGHT TRIANGLE🞁」は直角二等辺三角形です。最近はこれをorg-modeの見出しのマークとして使っています。開閉で見た目を変化させているので、閉じているときは🞂で開いているときは🞃にしています。

基本的なものだけでもこれだけあるわけですが、イマイチ使い分けがよく分かりません。子どもの頃漢字成り立ち辞典というのを持っていましたが、Unicode成り立ち辞典が欲しい。

他にも探せば三角形っぽいものは沢山あるみたいです。「BLACK * POINTING POINTER►」や「WHITE * POINTING POINTER▻」なんかは比較的上に挙げたものと同列に扱えそうです(2方向だけですが)。一方で三角形の形をした文字というのも多数あって、典型的なのはデルタΔですが、ここまでくるとどこまで「三角形」と呼ぶのかを考える必要があるでしょう。

それで、なんでこんなことをいきなり調べ始めたかというと、私のEmacsではこの辺りの記号が正しく表示できておらず、普段使っているフォントをFontForgeでいじって字形を調整している最中だからです。

2025-03-12 ,

Android版Emacsのためにした設定

先日Android版のEmacsを導入してみましたが、その後にした設定をまとめました。

これまでの設定:

以下はそれ以外の細かい設定です。

起動したらorgファイルを開く

私はこれまでEmacsで起動画面のカスタマイズなどは特にしていませんでした。とは言ってもデフォルトのスプラッシュスクリーンくらいはOFFにしていたので(つまり (setq inhibit-splash-screen t))、起動したらscratchバッファが表示される状態でした。

PCならこれで全く困らずそこから必要に応じてファイルやディレクトリを開けば済むわけですが、Androidスマホだと何を開くにも小さなボタンを何回も押さなければならず苦痛です(メニューバーの中のブックマークの位置と来たら……)。

なのでとりあえずデフォルトのorgファイルを最初から開くことにしました。その名も phone.org

とは言ってもやることは init.el の最後でfind-fileするだけです。一応OSとファイルの存在くらいは確認しておきましょうか。

(when (eq system-type 'android)
  (let ((home-file "~/my-org-files/phone.org"))
    (when (file-regular-p home-file)
      (find-file home-file))))

org-modeでメニューを作る

そうして開いたorgファイルにはよくアクセスするファイルやディレクトリへのリンクを書いておくわけです。

しかしそれだけでは足りません。よく使うコマンドもタッチで実行できるようにしておきたいところ。そんな時に便利なのが elisp: リンクです。 [[elisp:(message "Hello")][ハロー]] などと書けば押すとEmacs Lispの式が評価されるリンクが作成できます。description(ハローの部分)には画像を指定したりも出来るので工夫次第で綺麗な画面を作ることも出来ることでしょう。他人からもらったorgファイルだと何をされるか分からないので危険なリンクですが、自分が作ったものなら何の問題もありません。一応評価するかyes/noで聞かれるのでキーボードからだとy・e・s・RETと4ストローク必要ですが、Androidであればダイアログのyesをタップするだけです。むしろちょうど良いくらいです。

例えば次のような感じです(適当に似たようなものをでっち上げたので動作未確認)。

#+TITLE: Android用ホームファイル

- [[elisp:(my-ssh-setup)][ssh-agentの起動]]

- [[file:/data/data/com.termux/files/home/][Termux Home]]

- [[file:/data/data/org.gnu.emacs/files/][Emacs Home]]

- [[file:/sdcard/][SDCARD]]

- [[file:~/my-org-files/][Orgファイルたち]]
  - [[elisp:(my-git-pull "~/my-org-files/")][(my-git-pull)]]
  - [[elisp:(my-git-commit-push "~/my-org-files/")][(my-git-commit-push)]]
  - [[file:~/my-org-files/phone.org][phone.org]]
  - [[file:~/my-org-files/todo.org][todo.org]]
  - [[elisp:(org-agenda nil "a")][Agenda]]

- [[file:~/my-emacs-config/][Emacsの設定ファイル]]
  - [[elisp:(my-git-pull "~/my-emacs-config/")][(my-git-pull)]]
  - [[elisp:(magit-status "~/my-emacs-config/")][(magit-status)]]
  - [[file:~/my-emacs-config/init.el][init.el]]
  - [[file:~/my-emacs-config/early-init.el][early-init.el]]

各種ディレクトリへのリンクの他に、Gitのpushやpull、org-modeのアジェンダの表示なんかを入れておくと良いでしょう。これでAndroidからいつでも予定を確認できます。

Gitのためにあらかじめ次のような関数を用意しておきます。

(defun my-git-pull (dir)
  (let ((default-directory dir))
    (vc-pull)))

(defun my-git-commit-push (dir)
  (let ((default-directory dir))
    (unless
        (and (zerop (shell-command "git commit . -m \"Update\"" "*my-git-push*"))
             (zerop (shell-command "git push" "*my-git-push*")))
      (pop-to-buffer "*my-git-push*"))))

~/my-org-files/ の下は同期ソフト代わりにGitを使うようなイメージです。将来的には変更がぶつかったらある程度自動的にマージ(rebase?)するような仕掛けも欲しい所。

~/my-emacs-config/ の下はある程度ちゃんとコミットメッセージを書くためにとりあえずMagitを起動するリンクを載せておきましたが、Magitはなぜかものすごく遅いのでvc-checkinとvc-pushの方がいいかもしれません。

リンクを開くときに新しいウィンドウを開かない

使っていると何かにつけて新しいウィンドウを開いてくるのが気になります。PCでは分割ウィンドウが不要ならC-x 1を押せば済む話ですが、タッチ操作がメインの場合はウィンドウを閉じるのにも一手間必要です。画面が小さいので分割されると見づらくなってしまうということも関係しているのでしょう。

先日の設定(モードラインをドラッグしてウィンドウを消す)でウィンドウを簡単に閉じられるようになったとはいえ、そもそも最初から新しいウィンドウを開かなければいい話です。とは言え別ウィンドウを開くのが必ず悪いかと言われればよく分からないので、とりあえず気になったところだけ直すことにします。display-buffer-alistあたりを変更しようかとも思いましたが、とりあえず個々のコマンドの設定を変更してみます。

;; org-modeのリンクを開くときに別ウィンドウを開かない
(setf (alist-get 'file org-link-frame-setup) 'find-file)

;; org-agendaで別ウィンドウを開かない
(setq org-agenda-window-setup 'current-window)

;; Dired内でファイルをタップしたときに別ウィンドウを開かない
(define-key dired-mode-map [mouse-2] #'dired-mouse-find-file)

;; Diredから他のディレクトリを開くときに元のDiredバッファをkillする
;; (`dired-mouse-find-file'の挙動に影響する。
;;   dired-mouse-find-alter-fileは存在しない)
(setq dired-kill-when-opening-new-dired-buffer t)

おそらく他にも同じように感じる場所があると思いますが、気がついたら逐一設定していくことにします。

org-captureの保存先

ディレクトリ構成が変わったのでorg-captureの保存先も変える必要があります。……と思ったのですが、その後 ~/ とシンボリックリンクを組み合わせてPCと同じパスになるようにしてしまったので設定は不要になりました。

ファイルの自動同期

ファイルの同期自体は上に書いたとおりGitを同期ソフト代わりに使うことで実現しています。

その上でPCではファイルの保存やEmacsの終了のタイミングでファイルを同期するような仕組みを整備していましたがスマホでは止めておきました。電波が入らないところで使っている可能性があるので。とりあえず手動で同期しようと思います。

物理キーボードからのIMEのON/OFF

Bluetoothのハードウェアキーボードを接続してみたのですが、IMEのON/OFFの方法がよく分かりませんでした。半角/全角を押すと切り替わるように見えてOFFなのにM-<やM->を押すと<や>が入力されてしまったり、かと思えばそれらはコマンドとして認識されるけど半角/全角切り替えはできなかったり。

調べてみると、これはどうもtext-conversion-styleoverriding-text-conversion-styleが影響しているようです。

text-conversion-styleはIMEの挙動を指定するバッファローカル変数です。nilのときIME無効、非nilのとき有効になるようです。非nilの中でも、シンボル action やシンボル password といった指定もあります。ザッと検索してみたところ、text-mode(org-modeはここに含まれます)やprog-mode(一般的なプログラミング用のモードはここに含まれます)では tcomint-mode(shell-mode等がここに含まれます)やminibuffer-modeでは actionread-passwdでは password が指定されているようでした。つまりバッファ毎にそのバッファに入力される文字の種類・性質を指定するという側面があるようです。

それでtext-conversion-styleがnilのバッファではIMEを介さない入力が可能で、tのバッファでは常にIMEを介した入力になってしまうということのようです。極端だってば。(注: この辺りの挙動はIMEによっても若干異なるようです。Gboardはtext-conversion-styleがnilの時でもGboardのショートカットキーを完全に無効にすることはできませんでした。M->を押すと何か不可解なエラーが出たり、M-tを押すとGoogle翻訳を使おうとしたりします。ATOK Passport Proはショートカットキーが干渉することはありませんでしたが、text-conversion-styleがtのときは半角文字もIME経由でバッファへ直接挿入されるためorg-modeのスピードコマンドやedebugのアルファベット1文字のキーが使えなかったりしました)

text-conversion-styleの効果はグローバル変数overriding-text-conversion-styleで上書きできます。デフォルトの値はシンボル lambdatext-conversion-styleの値を尊重します。それ以外の値が指定されている場合は、text-conversion-styleは無視してoverriding-text-conversion-styleの値が使われるようです。

ということはこのoverriding-text-conversion-styleを切り替えるコマンドを作ればIMEの挙動をユーザーが明示的に指定出来るようになるはずです。

(defun my-toggle-text-conversion-style ()
  "`overriding-text-conversion-style'を`lambda'とnilとの間で切り替えます。"
  (interactive)
  (cond
   ((eq overriding-text-conversion-style 'lambda)
    (setq overriding-text-conversion-style nil)
    (set-text-conversion-style text-conversion-style))
   ((null overriding-text-conversion-style)
    (setq overriding-text-conversion-style 'lambda)
    (set-text-conversion-style text-conversion-style))))

それを私はCtrl+変換に割り当てました。私は普段PCでもこのキーでIMEのON/OFFを切り替えているので。

(define-key global-map [C-henkan] #'my-toggle-text-conversion-style)

半角/全角は英数/カナ切り替え(カナロック)のようなものだと考えることにします。

ちなみにハードウェアキーボードの細かいレイアウトはshiftrot/caps2ctrlkcmファイルを独自にカスタマイズして調整しています。

line-spacingや文字サイズの調整

指で位置を指定することを考えるとline-spacingは大きめが良いでしょうね。使うフォントにもよると思いますが。文字サイズは視力との兼ね合いでしょうか。

タッチによるスクロールをピクセル単位にする(2025-07-22追記)

(setq touch-screen-precision-scroll t)

慣性スクロールを有効にする(2025-11-13追記)

ツールバーの改善(2025-11-12追記)

ツールバーの項目を整理し、アイコン画像の大きさも調整して押しやすくしました。

Emacsのツールバーのカスタマイズ | Misohena Blog

おしまい

これでAndroid版のEmacsを触って最初に思いついた設定は一通り終わりました。

最初はAndroid版のEmacsなんて使い物になるの~? と疑っていましたが、思っていた以上に使える道具だということが分かってきました。Orgzlyはもう私には必要ありません。Android用に作られた昔からあるテキストエディタアプリも用済みです。タッチ操作で普通に編集できるので遜色ありません。長押しメニューが圧倒的に便利です。Dropbox等のクラウドストレージとの連携が必要な人だとまだその手の機能を搭載したアプリの方に分があるかもしれません……ってTrampのrcloneメソッドがあるんですね(使ったことはありません)。

すごいのはほぼ全ての設定がPCと同一だということです。Androidのためだけに設定したことは、これまでに紹介したものしかありません。

もちろん今後も触っていて思いついた改善をしていこうと思います。決して尽きることはないでしょう。

2025-03-11

長押しでコンテキストメニューを開く(Emacs 30)

Emacs 30からタッチ操作がサポートされました。これまでもOSによってはタッチ操作がマウス操作に変換されて操作可能だった場合もあると思いますが、Emacs 30からはEmacs自体がタッチイベントを認識してEmacs Lispからきめ細かい制御が可能になりました。

タッチスクリーンと言えばスマホやタブレットを連想しますが、PCにおいてもタッチスクリーン付きのディスプレイが搭載されていればタッチ操作が可能です。試しにタッチスクリーン付きの少し古めなノートPC(Windows 10)を使ってみたところ問題なく操作できました。

タッチスクリーンでできる操作についてはEmacs マニュアルの6.1 Using Emacs on Touchscreens(日本語訳)に書いてあります。それによればデフォルトでタップ、スクロール、ドラッグ、ピンチといった操作がサポートされています。マウスイベントへの変換も行われるので、従来のマウス用に用意された操作もタッチスクリーンからある程度実行可能です。

ある程度ということはもちろん全てではありません。マウスにはボタンが複数付いているのでかなり複雑な操作も可能ですが、現在のEmacsのタッチスクリーン操作はそれを全て再現できるようには出来ていません(加えて修飾キーとの組み合わせもサポートしていないようです)。

タッチで再現できない最たるものが右クリックでしょう。私は少し前からcontext-menu-modeを使用しているのでPCでは右クリックでコンテキストメニューが表示されますが、タッチ操作からではそれができません。スマホにおいても長押しでメニューが出るというのはかなり一般的な操作だと思います。

というわけで次のようなコードを書いて長押しでコンテキストメニューが表示されるようにしてみました。

(defun my-touch-screen-handle-point-up:around (old-fun point prefix canceled)
  (when (and (null touch-screen-aux-tool)
             (memq (nth 3 touch-screen-current-tool) '(held drag))
             (null prefix)
             (not canceled))
    ;; 右ボタン押し下げを再現するなら次のようにする。
    ;; ただ、続く解放イベント(mouse-3やdrag-mouse-3)も再現しないと色々良くない。
    ;; (throw 'input-event (list 'down-mouse-3 (cdr point)))
    ;; なので、とりあえず単にコンテキストメニューを開くだけにしておく。
    (context-menu-open)
    ;; 後続の処理(仮想キーボードの表示)をブロックするか少し迷う。
    )
  (funcall old-fun point prefix canceled))

(advice-add #'touch-screen-handle-point-up :around
            #'my-touch-screen-handle-point-up:around)

実際に使ってみた図:

タッチ長押しの後ドラッグして離したところ(Windows 10)
図1: タッチ長押しの後ドラッグして離したところ(Windows 10)

AndroidとWindows 10の両方で動くことを確認しました。

Emacsのタッチ操作に反応する部分はtouch-screen.elに書かれています。複雑なので全部は読んでいませんが、長押し、ドラッグ、解放に関する流れを大ざっぱに追ってみました。長押しするとheldという状態に移行し、そのまま指を動かすとdragという状態に移行します。heldまたはdragの状態で指を離したとき、デフォルトではなぜか仮想キーボードを開く処理が行われるのですが(おそらく選択した範囲に対して仮想キーボードでC-wやM-wしろということなのかもしれません)、上のコードはその前に割り込んでコンテキストメニューを表示させます。

コンテキストメニューが不要ならそのままフレームをタップしたり(Windows 10)、ウィンドウ内の適当な場所をタップしたり(Android)すればコンテキストメニューは閉じます。その時ポイントやアクティブリージョンが変わったりはしません(注: Windows 10の時はウィンドウ内をタップするとメニューが閉じるだけで無くポイントも移動してしまいました。フレームのタイトルバー等をタップしましょう)。なのでコンテキストメニューを表示させることにそれほど害は無いでしょう。

むしろそのままカットやコピーが選べるのはかなり楽なはずです。

上のコードでは右クリックを再現するのでは無くcontext-menu-openを直接呼び出しています。本来は右クリックを再現した方が良いと思うのですが、マウス右ボタン押し下げイベント(down-mouse-3)だけでなく解放イベント(mouse-3またはdrag-mouse-3)も一緒に再現するのが面倒なので止めておきました。パッケージによってはdown-mouse-3やmouse-3でメニューを表示しているものがあるので、本来ならちゃんと再現した方が用途が広がると思います。

マニュアルにも書いてありますが、より良い選択操作のため次の変数をtにしておくと良いでしょう。

(setq touch-screen-word-select t ;; 単語単位の選択(日本語だとちょっとつらい)
      touch-screen-extend-selection t ;; 後からポイント長押しで選択範囲の拡張
      touch-screen-preview-select t) ;; ミニバッファにドラッグ中の場所を表示

(相変わらずEmacsというのはこの手の便利機能をデフォルトで有効にしたがりませんね。これだから初心者が逃げていくのです。使っていて邪魔だなと思ったらnilにすれば良いだけです)

何はともあれ、AndroidでのEmacsが大分使いやすくなってきました。