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パネル

付属品。「width 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の二つになっています(MASTERとSLAVE)。

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

バックフリップ型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/examples/epd_13in3E_test.py 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)

おしまい

これで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が大分使いやすくなってきました。

2025-03-08

Emacsでキー入力を補助するためのツールバーを改善する

Emacs 30からmodifier-bar-modeが追加されました。これは修飾キーの入力ボタンを表示するグローバルマイナーモードです。

M-x modifier-bar-modeとタイプすると次のようなツールバーが表示されます(ツールバーの一種なので、先にtool-bar-mode等でツールバー自体も表示しておく必要があると思います)。

modifier-bar-mode
図1: modifier-bar-mode

見ての通りボタンはCtrl、Shift、Meta、Alt、Super、Hyperの六つです。例えばC-M-iと入力したければCtrlボタンとMetaボタンを押してからiを押せば良いということになります。

もちろん普通のキーボードを使っていれば必要になることはほとんど無いと思いますが、AndroidでCtrl等が無いソフトキーボードを使っているときは重宝するでしょう。PCでも左手でポテチをつまみながら右手でマウスを握りリラックスしながらポチポチやってるときには便利かもしれません。

とは言え、実際Androidスマホでこれを使ってみたところ、案外痒いところに手が届かないと言いましょうか、これじゃ全然足りないよ! という気持ちになりました。

スマホで使用するソフトキーボードというのは本当にキーが小さくて押しづらいです。Hacker's Keyboardはちゃんと修飾キーも押せるソフトキーボードですが、Ctrl、xと押すだけでもキーが小さすぎて神経を使います。1キーでC-xまで入力させてほしいのです。他にも1ボタンで入力できれば便利なキーは沢山あります。

そこで、このmodifier-bar-modeを元に自分なりのキー入力補助ツールバーを作ってみました。それを有効にすると次のようになります。

my-tool-bar-mode
図2: my-tool-bar-mode
  • 修飾キーは C- S- M- の三つで簡素に表示します。
  • A- s- H- はスペースの都合で諦めました。どうせ使わないでしょう。
  • C-x C-c というプレフィックスはやはり1ボタンで入力したいところ。
  • 追加で C-xC-c の後にさらに C- を続けるパターンもボタン化しました。
  • M-x はメニューのEdit→Execute Commandでも呼び出せますが、やはり1ボタンにしたいです。
  • C-g もなんだかんだ言って欲しくなります。
  • 矢印キーの上と下は普通の日本語IMEを使っているときに重宝します。GboardにせよATOKにせよ左右キーはあっても上下キーは無いからです。

というわけで以下ソースコード。

(require 'tool-bar)

(defconst my-tool-bar-svg-alist
  '(("C-" . "<g><path d=\"M9.0 5.2C9.0 5.0 9.0 4.6 8.5 4.6 8.2 4.6 8.1 4.7 8.0 4.8 8.0 4.9 8.0 4.9 7.8 5.4 7.2 4.9 6.5 4.6 5.6 4.6 3.5 4.6 1.6 6.8 1.6 9.8 1.6 12.9 3.5 15.1 5.6 15.1 7.6 15.1 9.0 13.6 9.0 12.1 9.0 11.6 8.6 11.6 8.4 11.6 8.3 11.6 7.9 11.6 7.9 12.1 7.7 13.7 6.4 14.1 5.7 14.1 4.2 14.1 2.8 12.3 2.8 9.8 2.8 7.3 4.2 5.6 5.7 5.6 6.7 5.6 7.6 6.3 7.8 7.7 7.9 8.0 7.9 8.2 8.4 8.2 9.0 8.2 9.0 7.9 9.0 7.6Z\" /><path d=\"M16.7 10.4C16.9 10.4 17.5 10.4 17.5 9.8S16.9 9.2 16.7 9.2H11.4C11.2 9.2 10.6 9.2 10.6 9.8 10.6 10.4 11.2 10.4 11.4 10.4Z\" /></g>")
    ("S-" . "<g><path d=\"M5.9 10.3C6.3 10.4 7.7 10.8 7.7 12.2 7.7 13.1 7.0 14.0 5.7 14.0 5.2 14.0 4.4 14.0 3.7 13.5 3.0 13.1 3.0 12.4 3.0 12.1 2.9 11.8 2.9 11.5 2.4 11.5 1.8 11.5 1.8 11.9 1.8 12.2V14.4C1.8 14.6 1.8 15.1 2.3 15.1 2.7 15.1 2.8 14.8 2.9 14.3 3.6 14.8 4.6 15.1 5.7 15.1 7.6 15.1 8.8 13.6 8.8 12.1 8.8 11.1 8.2 10.4 8.0 10.1 7.3 9.5 6.9 9.4 5.6 9.1L4.2 8.8C3.5 8.6 2.9 8.0 2.9 7.2 2.9 6.3 3.7 5.5 4.9 5.5 6.9 5.5 7.1 7.1 7.2 7.6 7.3 8.0 7.4 8.1 7.8 8.1 8.4 8.1 8.4 7.7 8.4 7.4V5.2C8.4 4.9 8.4 4.5 7.9 4.5 7.5 4.5 7.4 4.8 7.2 5.3 6.6 4.7 5.7 4.5 4.9 4.5 3.1 4.5 1.8 5.8 1.8 7.3 1.8 8.4 2.6 9.5 4.0 9.9 4.0 9.9 5.6 10.3 5.9 10.3Z\" /><path d=\"M16.7 10.4C16.9 10.4 17.5 10.4 17.5 9.8S16.9 9.2 16.7 9.2H11.4C11.2 9.2 10.6 9.2 10.6 9.8S11.2 10.4 11.4 10.4Z\" /></g>")
    ("M-" . "<g><path d=\"M5.8 8.7C5.6 9.3 5.4 9.8 5.3 10.3H5.3C5.1 9.5 3.7 5.2 3.6 5.1 3.5 4.7 3.1 4.7 2.8 4.7H1.9C1.6 4.7 1.2 4.7 1.2 5.2 1.2 5.7 1.6 5.7 2.1 5.7V13.8C1.6 13.8 1.2 13.8 1.2 14.3 1.2 14.8 1.6 14.8 1.9 14.8H3.3C3.5 14.8 3.9 14.8 3.9 14.3 3.9 13.8 3.6 13.8 3.0 13.8V5.9H3.1C3.2 6.7 4.3 9.9 4.4 10.1 4.5 10.5 4.7 11.1 4.8 11.2 4.9 11.3 5.1 11.5 5.3 11.5 5.5 11.5 5.8 11.3 5.9 11.1 5.9 11.0 7.4 6.8 7.6 5.9H7.6V13.8C7.0 13.8 6.7 13.8 6.7 14.3 6.7 14.8 7.1 14.8 7.4 14.8H8.7C9.0 14.8 9.4 14.8 9.4 14.3 9.4 13.8 9.0 13.8 8.5 13.8V5.7C9.0 5.7 9.4 5.7 9.4 5.2 9.4 4.7 9.0 4.7 8.7 4.7H7.8C7.1 4.7 7.0 4.9 6.9 5.3Z\" /><path d=\"M16.7 10.3C16.9 10.3 17.5 10.3 17.5 9.8S16.9 9.2 16.7 9.2H11.4C11.2 9.2 10.6 9.2 10.6 9.8S11.2 10.3 11.4 10.3Z\" /></g>")
    ("A-" . "<g><path d=\"M6.2 5.1C6.1 4.5 5.8 4.4 5.3 4.4 4.8 4.4 4.6 4.4 4.4 5.1L2.4 13.8C2.2 13.8 1.8 13.8 1.7 13.8 1.5 13.9 1.4 14.1 1.4 14.3 1.4 14.8 1.8 14.8 2.1 14.8H3.8C4.1 14.8 4.5 14.8 4.5 14.3 4.5 13.8 4.2 13.8 3.6 13.8L4.0 12.1H6.7L7.0 13.8C6.4 13.8 6.1 13.8 6.1 14.3 6.1 14.8 6.5 14.8 6.8 14.8H8.5C8.8 14.8 9.2 14.8 9.2 14.3 9.2 14.1 9.1 13.9 8.9 13.8 8.8 13.8 8.5 13.8 8.2 13.8ZM5.3 5.8H5.3L6.4 11.1H4.2Z\" /><path d=\"M16.7 10.3C16.9 10.3 17.5 10.3 17.5 9.7S16.9 9.1 16.7 9.1H11.4C11.2 9.1 10.6 9.1 10.6 9.7S11.2 10.3 11.4 10.3Z\" /></g>")
    ("s-" . "<g><path d=\"M5.9 10.5C5.5 10.5 5.2 10.4 4.8 10.3 4.3 10.3 3.2 10.1 3.2 9.4 3.2 9.0 3.7 8.5 5.3 8.5 6.7 8.5 6.9 9.0 6.9 9.4 7.0 9.7 7.0 10.0 7.5 10.0 8.1 10.0 8.1 9.6 8.1 9.3V8.1C8.1 7.9 8.1 7.5 7.6 7.5 7.2 7.5 7.1 7.7 7.1 7.8 6.4 7.5 5.6 7.5 5.3 7.5 2.5 7.5 2.1 8.8 2.1 9.4 2.1 10.9 3.9 11.2 5.4 11.5 6.2 11.6 7.6 11.8 7.6 12.7 7.6 13.3 7.0 13.9 5.4 13.9 4.7 13.9 3.7 13.7 3.3 12.4 3.2 12.1 3.2 11.9 2.7 11.9 2.1 11.9 2.1 12.2 2.1 12.6V14.2C2.1 14.5 2.1 14.9 2.6 14.9 2.8 14.9 3.1 14.9 3.3 14.2 4.1 14.8 5.0 14.9 5.4 14.9 8.1 14.9 8.6 13.5 8.6 12.7 8.6 11.0 6.4 10.6 5.9 10.5Z\" /><path d=\"M16.7 10.3C16.9 10.3 17.5 10.3 17.5 9.7S16.9 9.1 16.7 9.1H11.4C11.2 9.1 10.6 9.1 10.6 9.7S11.2 10.3 11.4 10.3Z\" /></g>")
    ("H-" . "<g><path d=\"M8.2 5.6H8.6C8.9 5.6 9.3 5.6 9.3 5.1 9.3 4.6 8.9 4.6 8.6 4.6H6.7C6.4 4.6 6.0 4.6 6.0 5.1 6.0 5.6 6.4 5.6 6.7 5.6H7.1V9.0H3.5V5.6H4.0C4.2 5.6 4.6 5.6 4.6 5.1 4.6 4.6 4.2 4.6 4.0 4.6H2.0C1.7 4.6 1.3 4.6 1.3 5.1 1.3 5.6 1.7 5.6 2.0 5.6H2.4V13.7H2.0C1.7 13.7 1.3 13.7 1.3 14.2 1.3 14.7 1.7 14.7 2.0 14.7H4.0C4.2 14.7 4.6 14.7 4.6 14.2 4.6 13.7 4.2 13.7 4.0 13.7H3.5V10.0H7.1V13.7H6.7C6.4 13.7 6.0 13.7 6.0 14.2 6.0 14.7 6.4 14.7 6.7 14.7H8.6C8.9 14.7 9.3 14.7 9.3 14.2 9.3 13.7 8.9 13.7 8.6 13.7H8.2Z\" /><path d=\"M16.7 10.2C16.9 10.2 17.5 10.2 17.5 9.6S16.9 9.1 16.7 9.1H11.4C11.2 9.1 10.6 9.1 10.6 9.6S11.2 10.2 11.4 10.2Z\" /></g>")
    ("Mx" . "<g><path d=\"M5.8 8.6C5.6 9.2 5.4 9.6 5.3 10.1H5.3C5.1 9.3 3.7 5.0 3.6 4.9 3.5 4.5 3.1 4.5 2.8 4.5H1.9C1.6 4.5 1.2 4.5 1.2 5.0 1.2 5.6 1.6 5.6 2.1 5.6V13.7C1.6 13.7 1.2 13.7 1.2 14.2 1.2 14.7 1.6 14.7 1.9 14.7H3.3C3.5 14.7 3.9 14.7 3.9 14.2 3.9 13.7 3.6 13.7 3.0 13.7V5.8H3.1C3.2 6.5 4.3 9.7 4.4 9.9 4.5 10.3 4.7 10.9 4.8 11.1 4.9 11.2 5.1 11.3 5.3 11.3 5.5 11.3 5.8 11.2 5.9 10.9 5.9 10.8 7.4 6.6 7.6 5.8H7.6V13.7C7.0 13.7 6.7 13.7 6.7 14.2 6.7 14.7 7.1 14.7 7.4 14.7H8.7C9.0 14.7 9.4 14.7 9.4 14.2 9.4 13.7 9.0 13.7 8.5 13.7V5.6C9.0 5.6 9.4 5.6 9.4 5.0 9.4 4.5 9.0 4.5 8.7 4.5H7.8C7.1 4.5 7.0 4.8 6.9 5.2Z\" /><path d=\"M14.5 11.0 16.4 8.6H17.0C17.3 8.6 17.7 8.6 17.7 8.1 17.7 7.5 17.3 7.5 17.0 7.5H15.1C14.8 7.5 14.4 7.5 14.4 8.0 14.4 8.6 14.8 8.6 15.2 8.6L14.0 10.2 12.7 8.6C13.2 8.6 13.5 8.6 13.5 8.0 13.5 7.5 13.1 7.5 12.9 7.5H10.9C10.7 7.5 10.2 7.5 10.2 8.1 10.2 8.6 10.7 8.6 10.9 8.6H11.6L13.5 11.0 11.5 13.7H10.8C10.6 13.7 10.1 13.7 10.1 14.2 10.1 14.7 10.6 14.7 10.8 14.7H12.8C13.0 14.7 13.4 14.7 13.4 14.2 13.4 13.7 13.1 13.7 12.6 13.7L14.0 11.6 15.5 13.7C15.0 13.7 14.6 13.7 14.6 14.2 14.6 14.7 15.1 14.7 15.3 14.7H17.3C17.5 14.7 17.9 14.7 17.9 14.2 17.9 13.7 17.5 13.7 17.3 13.7H16.6Z\" /></g>")
    ("Cx" . "<g><path d=\"M9.0 5.0C9.0 4.7 9.0 4.3 8.5 4.3 8.2 4.3 8.1 4.5 8.0 4.6 8.0 4.7 8.0 4.7 7.8 5.1 7.2 4.7 6.5 4.3 5.6 4.3 3.5 4.3 1.6 6.6 1.6 9.6 1.6 12.6 3.5 14.9 5.6 14.9 7.6 14.9 9.0 13.4 9.0 11.9 9.0 11.3 8.6 11.3 8.4 11.3 8.3 11.3 7.9 11.3 7.9 11.8 7.7 13.4 6.4 13.8 5.7 13.8 4.2 13.8 2.8 12.1 2.8 9.6 2.8 7.1 4.2 5.3 5.7 5.3 6.7 5.3 7.6 6.1 7.8 7.5 7.9 7.7 7.9 8.0 8.4 8.0 9.0 8.0 9.0 7.7 9.0 7.3Z\" /><path d=\"M14.5 11.0 16.4 8.5H17.0C17.3 8.5 17.7 8.5 17.7 8.0 17.7 7.5 17.3 7.5 17.0 7.5H15.1C14.8 7.5 14.4 7.5 14.4 8.0 14.4 8.5 14.8 8.5 15.2 8.5L14.0 10.2 12.7 8.5C13.2 8.5 13.5 8.5 13.5 8.0 13.5 7.5 13.1 7.5 12.9 7.5H10.9C10.7 7.5 10.2 7.5 10.2 8.0 10.2 8.5 10.7 8.5 10.9 8.5H11.6L13.5 11.0 11.5 13.7H10.8C10.6 13.7 10.1 13.7 10.1 14.2 10.1 14.7 10.6 14.7 10.8 14.7H12.8C13.0 14.7 13.4 14.7 13.4 14.2 13.4 13.7 13.1 13.7 12.6 13.7L14.0 11.6 15.5 13.7C15.0 13.7 14.6 13.7 14.6 14.2 14.6 14.7 15.1 14.7 15.3 14.7H17.3C17.5 14.7 17.9 14.7 17.9 14.2 17.9 13.7 17.5 13.7 17.3 13.7H16.6Z\" /></g>")
    ("CxC" . "<g><path d=\"M9.0 5.0C9.0 4.7 9.0 4.3 8.5 4.3 8.2 4.3 8.1 4.5 8.0 4.6 8.0 4.6 8.0 4.7 7.8 5.1 7.2 4.6 6.5 4.3 5.6 4.3 3.5 4.3 1.6 6.5 1.6 9.5 1.6 12.6 3.5 14.8 5.6 14.8 7.6 14.8 9.0 13.3 9.0 11.8 9.0 11.3 8.6 11.3 8.4 11.3 8.3 11.3 7.9 11.3 7.9 11.8 7.7 13.4 6.4 13.8 5.7 13.8 4.2 13.8 2.8 12.0 2.8 9.5 2.8 7.1 4.2 5.3 5.7 5.3 6.7 5.3 7.6 6.1 7.8 7.4 7.9 7.7 7.9 8.0 8.4 8.0 9.0 8.0 9.0 7.6 9.0 7.3Z\" /><path d=\"M14.5 10.9 16.4 8.5H17.0C17.3 8.5 17.7 8.5 17.7 8.0 17.7 7.5 17.3 7.5 17.0 7.5H15.1C14.8 7.5 14.4 7.5 14.4 8.0 14.4 8.5 14.8 8.5 15.2 8.5L14.0 10.2 12.7 8.5C13.2 8.5 13.5 8.5 13.5 8.0 13.5 7.5 13.1 7.5 12.9 7.5H10.9C10.7 7.5 10.2 7.5 10.2 8.0 10.2 8.5 10.7 8.5 10.9 8.5H11.6L13.5 10.9 11.5 13.6H10.8C10.6 13.6 10.1 13.6 10.1 14.1 10.1 14.6 10.6 14.6 10.8 14.6H12.8C13.0 14.6 13.4 14.6 13.4 14.1 13.4 13.6 13.1 13.6 12.6 13.6L14.0 11.6 15.5 13.6C15.0 13.6 14.6 13.6 14.6 14.1 14.6 14.6 15.1 14.6 15.3 14.6H17.3C17.5 14.6 17.9 14.6 17.9 14.1 17.9 13.6 17.5 13.6 17.3 13.6H16.6Z\" /><path d=\"M26.5 5.0C26.5 4.7 26.5 4.3 26.0 4.3 25.7 4.3 25.6 4.5 25.5 4.6 25.5 4.6 25.5 4.7 25.3 5.1 24.7 4.6 23.9 4.3 23.1 4.3 20.9 4.3 19.1 6.5 19.1 9.5 19.1 12.6 20.9 14.8 23.1 14.8 25.0 14.8 26.5 13.3 26.5 11.8 26.5 11.3 26.1 11.3 25.9 11.3 25.7 11.3 25.4 11.3 25.3 11.8 25.2 13.4 23.9 13.8 23.2 13.8 21.7 13.8 20.2 12.0 20.2 9.5 20.2 7.1 21.7 5.3 23.2 5.3 24.2 5.3 25.1 6.1 25.3 7.4 25.4 7.7 25.4 8.0 25.9 8.0 26.5 8.0 26.5 7.6 26.5 7.3Z\" /></g>")
    ("Cc" . "<g><path d=\"M9.0 4.9C9.0 4.7 9.0 4.3 8.5 4.3 8.2 4.3 8.1 4.4 8.0 4.5 8.0 4.6 8.0 4.6 7.8 5.1 7.2 4.6 6.5 4.3 5.6 4.3 3.5 4.3 1.6 6.5 1.6 9.5 1.6 12.6 3.5 14.8 5.6 14.8 7.6 14.8 9.0 13.3 9.0 11.8 9.0 11.3 8.6 11.3 8.4 11.3 8.3 11.3 7.9 11.3 7.9 11.7 7.7 13.4 6.4 13.8 5.7 13.8 4.2 13.8 2.8 12.0 2.8 9.5 2.8 7.0 4.2 5.3 5.7 5.3 6.7 5.3 7.6 6.0 7.8 7.4 7.9 7.7 7.9 7.9 8.4 7.9 9.0 7.9 9.0 7.6 9.0 7.2Z\" /><path d=\"M17.4 12.8C17.4 12.3 17.0 12.3 16.9 12.3 16.6 12.3 16.4 12.4 16.3 12.7 16.2 12.9 15.9 13.7 14.7 13.7 13.2 13.7 12.0 12.5 12.0 11.0 12.0 10.2 12.5 8.3 14.8 8.3 15.1 8.3 15.8 8.3 15.8 8.4 15.8 9.0 16.1 9.3 16.5 9.3S17.2 9.0 17.2 8.5C17.2 7.3 15.5 7.3 14.8 7.3 11.9 7.3 10.9 9.5 10.9 11.0 10.9 13.0 12.5 14.7 14.6 14.7 16.9 14.7 17.4 13.1 17.4 12.8Z\" /></g>")
    ("CcC" . "<g><path d=\"M9.0 4.9C9.0 4.6 9.0 4.2 8.5 4.2 8.2 4.2 8.1 4.4 8.0 4.5 8.0 4.6 8.0 4.6 7.8 5.0 7.2 4.6 6.5 4.2 5.6 4.2 3.5 4.2 1.6 6.4 1.6 9.5 1.6 12.5 3.5 14.7 5.6 14.7 7.6 14.7 9.0 13.3 9.0 11.8 9.0 11.2 8.6 11.2 8.4 11.2 8.3 11.2 7.9 11.2 7.9 11.7 7.7 13.3 6.4 13.7 5.7 13.7 4.2 13.7 2.8 12.0 2.8 9.5 2.8 7.0 4.2 5.2 5.7 5.2 6.7 5.2 7.6 6.0 7.8 7.3 7.9 7.6 7.9 7.9 8.4 7.9 9.0 7.9 9.0 7.5 9.0 7.2Z\" /><path d=\"M17.4 12.8C17.4 12.3 17.0 12.3 16.9 12.3 16.6 12.3 16.4 12.3 16.3 12.7 16.2 12.9 15.9 13.7 14.7 13.7 13.2 13.7 12.0 12.5 12.0 11.0 12.0 10.2 12.5 8.3 14.8 8.3 15.1 8.3 15.8 8.3 15.8 8.4 15.8 9.0 16.1 9.2 16.5 9.2S17.2 8.9 17.2 8.5C17.2 7.2 15.5 7.2 14.8 7.2 11.9 7.2 10.9 9.5 10.9 11.0 10.9 13.0 12.5 14.7 14.6 14.7 16.9 14.7 17.4 13.0 17.4 12.8Z\" /><path d=\"M26.5 4.9C26.5 4.6 26.5 4.2 26.0 4.2 25.7 4.2 25.6 4.4 25.5 4.5 25.5 4.6 25.5 4.6 25.3 5.0 24.7 4.6 23.9 4.2 23.1 4.2 20.9 4.2 19.1 6.4 19.1 9.5 19.1 12.5 20.9 14.7 23.1 14.7 25.0 14.7 26.5 13.3 26.5 11.8 26.5 11.2 26.1 11.2 25.9 11.2 25.7 11.2 25.4 11.2 25.3 11.7 25.2 13.3 23.9 13.7 23.2 13.7 21.7 13.7 20.2 12.0 20.2 9.5 20.2 7.0 21.7 5.2 23.2 5.2 24.2 5.2 25.1 6.0 25.3 7.3 25.4 7.6 25.4 7.9 25.9 7.9 26.5 7.9 26.5 7.5 26.5 7.2Z\" /></g>")
    ("Cg" . "<g><path d=\"M9.0 4.9C9.0 4.6 9.0 4.2 8.5 4.2 8.2 4.2 8.1 4.3 8.0 4.5 8.0 4.5 8.0 4.6 7.8 5.0 7.2 4.5 6.5 4.2 5.6 4.2 3.5 4.2 1.6 6.4 1.6 9.4 1.6 12.5 3.5 14.7 5.6 14.7 7.6 14.7 9.0 13.2 9.0 11.7 9.0 11.2 8.6 11.2 8.4 11.2 8.3 11.2 7.9 11.2 7.9 11.7 7.7 13.3 6.4 13.7 5.7 13.7 4.2 13.7 2.8 11.9 2.8 9.4 2.8 7.0 4.2 5.2 5.7 5.2 6.7 5.2 7.6 6.0 7.8 7.3 7.9 7.6 7.9 7.9 8.4 7.9 9.0 7.9 9.0 7.5 9.0 7.2Z\" /><path d=\"M13.6 11.6C12.7 11.6 12.0 10.8 12.0 9.9 12.0 9.0 12.7 8.3 13.6 8.3 14.5 8.3 15.2 9.0 15.2 9.9 15.2 10.9 14.4 11.6 13.6 11.6ZM12.1 12.2C12.1 12.2 12.7 12.6 13.6 12.6 15.1 12.6 16.3 11.4 16.3 9.9 16.3 9.4 16.2 8.9 15.9 8.5 16.2 8.3 16.6 8.2 16.8 8.2 16.9 8.6 17.3 8.8 17.5 8.8 17.8 8.8 18.2 8.6 18.2 8.1 18.2 7.7 17.8 7.2 16.9 7.2 16.8 7.2 15.9 7.2 15.2 7.8 14.9 7.6 14.3 7.3 13.6 7.3 12.0 7.3 10.8 8.5 10.8 9.9 10.8 10.6 11.1 11.2 11.3 11.5 11.2 11.8 11.0 12.1 11.0 12.6 11.0 13.2 11.2 13.6 11.4 13.8 10.2 14.6 10.2 15.7 10.2 15.9 10.2 17.3 11.9 18.3 14.0 18.3 16.2 18.3 17.9 17.3 17.9 15.9 17.9 15.3 17.6 14.4 16.8 14.0 16.6 13.9 15.9 13.5 14.4 13.5H13.2C13.1 13.5 12.8 13.5 12.7 13.5 12.5 13.5 12.4 13.5 12.2 13.3 12.0 13.0 12.0 12.7 12.0 12.7 12.0 12.6 12.0 12.4 12.1 12.2ZM14.0 17.3C12.4 17.3 11.1 16.6 11.1 15.9 11.1 15.6 11.3 15.1 11.8 14.7 12.2 14.5 12.4 14.5 13.6 14.5 15.1 14.5 17.0 14.5 17.0 15.9 17.0 16.6 15.7 17.3 14.0 17.3Z\" /></g>")
    ("Up" . "<path d=\"M10 1 5.5 10H9V19H11V10H14.5Z\" />")
    ("Do" . "<path d=\"M10 19 5.5 10H9V1H11V10H14.5Z\" />")))

(defun my-tool-bar-image (text)
  "TEXTに対応する画像(Image Descriptor)を返す。"
  (when-let* ((svg (alist-get text my-tool-bar-svg-alist nil nil #'equal)))
    (list 'image
          :type 'svg :data
          (concat
           (format "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"%s\" height=\"%s\" fill=\"#000\" viewBox=\"0 0 %s %s\" >"
                   ;; 押しづらいのでサイズを調整
                   ;; 元のSVGは1文字が高さ20、幅が10弱くらいの枠内に収まるように書いてある
                   (* (length text) 16) 28
                   (* (length text) 10) 20)
           svg "</svg>")
          :scale 'default)))

(define-minor-mode my-tool-bar-mode
  "私だけの特別なツールバー。"
  :init-value nil
  :global t
  :group 'tool-bar
  (if my-tool-bar-mode
      (setq secondary-tool-bar-map
            (let ((km (make-sparse-keymap)))
              (dolist (key (reverse '("C-" "S-" "M-" ;; "A-" "s-" "H-"
                                      "Mx" "Cx" "CxC" "Cc" "CcC"
                                      "Cg" "Up" "Do")))
                (my-tool-bar-add-prefix-item km key))
              km))
    (setq secondary-tool-bar-map nil))
  ;; Update the mode line now.
  (force-mode-line-update t))

(defun my-tool-bar-decode-C- (_prompt)
  (modifier-bar-button '(control)))

(defun my-tool-bar-decode-S- (_prompt)
  (modifier-bar-button '(shift)))

(defun my-tool-bar-decode-M- (_prompt)
  (modifier-bar-button '(meta)))

(defun my-tool-bar-decode-A- (_prompt)
  (modifier-bar-button '(alt)))

(defun my-tool-bar-decode-s- (_prompt)
  (modifier-bar-button '(super)))

(defun my-tool-bar-decode-H- (_prompt)
  (modifier-bar-button '(hyper)))

(defun my-tool-bar-decode-Mx (_prompt)
  (kbd "M-x"))

(defun my-tool-bar-decode-Cx (_prompt)
  (kbd "C-x"))

(defun my-tool-bar-decode-CxC (_prompt)
  (vconcat
   (kbd "C-x")
   (modifier-bar-button '(control))))

(defun my-tool-bar-decode-Cc (_prompt)
  (kbd "C-c"))

(defun my-tool-bar-decode-CcC (_prompt)
  (vconcat
   (kbd "C-c")
   (modifier-bar-button '(control))))

(defun my-tool-bar-decode-Cg (_prompt)
  (kbd "C-g"))

(defun my-tool-bar-decode-Up (_prompt)
  (kbd "<up>"))

(defun my-tool-bar-decode-Do (_prompt)
  (kbd "<down>"))

(defun my-tool-bar-add-prefix-item (km key-str)
  (let* ((modifier (alist-get key-str
                              '(("C-" . control) ("S-" . shift)
                                ("M-" . meta) ("A-" . alt)
                                ("s-" . super) ("H-" . hyper))
                              nil nil #'equal))
         (key-sym (or modifier
                      (intern key-str)))
         (decode-fun (intern (format "my-tool-bar-decode-%s" key-str))))
    (define-key km (vector key-sym)
                (list
                 'menu-item key-str #'ignore
                 :image (my-tool-bar-image key-str)
                 :help (if modifier
                           (format "Add %s to the following event" key-str)
                         key-str)
                 :enable (if modifier
                             `(modifier-bar-available-p (quote ,modifier))
                           t)))
    (define-key input-decode-map (vector 'tool-bar key-sym) decode-fun)))

実装の一部は modifier-bar-mode のコードを呼び出しています。

入力イベントに修飾キーを付加するのにinput-decode-mapというのを利用しているみたいです。そこから呼び出される関数modifier-bar-buttonにread-eventのループがあって、そこで入力された後続のイベントに修飾キーを付加して返すと、それが代わりの入力結果になるという流れのようです。

ツールバーへのボタンの表示はsecondary-tool-bar-mapにメニュー項目を追加することで行います。

ただし、項目文字列が表示されることは無く、代わりに画像を指定してやる必要があります。modifier-bar-modeのCtrlやらShiftやらの文字がどうも汚いなと思っていましたが、これはテキストでは無く画像で用意されています。image-load-path (etc/images/) の下に ctrl.pbm や shift.pbm といったファイル名で存在しています。

わざわざ画像を用意するのは億劫だなと思いましたが、試してみたところSVG画像でも表示できるようでした(WindowsとAndroidの両方で確認)。

しかしここで残念なお知らせが。

現在のAndroid版EmacsはSVGでtext要素が表示できません。

最初はフォントが見つけられないだけかなとも思ったのですが、 java/INSTALL の中に次のような記述を見つけました。

LIBRSVG

Librsvg 2.40.21, the final release in the librsvg 2.40.x series, the
last to be implemented in C, is provided as:

  librsvg-2.40.21-emacs.tar.gz

and has been lightly edited for compatibility with environments where
Pango cannot provide fonts, with the obvious caveat that text cannot be
displayed with the resulting librsvg binary.  Among numerous
dependencies are PCRE, and:

  libiconv-1.17-emacs.tar.gz
  libffi-3.4.5-emacs.tar.gz
  pango-1.38.1-emacs.tar.gz
  glib-2.33.14-emacs.tar.gz
  libcroco-0.6.13-emacs.tar.gz
  pixman-0.38.4-emacs.tar.gz
  libxml2-2.12.4-emacs.tar.gz
  gdk-pixbuf-2.22.1-emacs.tar.gz
  giflib-5.2.1-emacs.tar.gz
  libjpeg-turbo-3.0.2-emacs.tar.gz
  libpng-1.6.41-emacs.tar.gz
  tiff-4.5.1-emacs.tar.gz
  cairo-1.16.0-emacs.tar.gz

which must be individually unpacked and their contents provided on the
command line, as with other dependencies.  They will introduce
approximately 8 MiB's worth of shared libraries into the finished
application package.  It is unlikely that later releases of librsvg will
ever be ported, as they have migrated to a different implementation
language.

No effort has been expended on providing the latest and greatest of
these dependencies either; rather, the versions chosen are often the
earliest versions required by their dependents, these being the smaller
of all available versions, and generally more straightforward to port.

よく分かりませんが、テキストが表示できないのは仕様ということなのでしょうか? librsvg-2.40自体もかなり古いものなのでサポートしていないSVGの機能やバグが沢山あるはずです。 残念ですがとりあえずSVGでtext要素を使うのは諦めるよりありません。

というわけで上のソースコードにはLaTeXで生成した文字のパスデータが長々と並んでいるわけです。

2025-03-07

Emacsからssh-agentを起動する

ssh-agentというのは基本的にはEmacsが起動する前に実行しておくものだと思います。しかしAndroid版の場合、(Termuxの(非GUIの)Emacsパッケージだったりrootを取って色々したりしない限り)普通はそんなことは不可能です。ダイレクトにEmacsが起動してしまうので。

Android版のEmacsはTermuxと連携させればsshが使用できます。Magitも動作します(なぜかWindowsバリに遅いですが)。でもssh-agentが起動していなければ毎回パスフレーズを入力しなければなりません(設定しているのなら。Trampに限ってはauthinfoに記録させる方法もありますが)。

となると、Emacsからssh-agentを起動するという方法が浮かんできます。

やることは簡単。ssh-agentを起動して出力される環境変数(SSH_AUTH_SOCKSSH_AGENT_PID)をEmacs自体に反映してやれば良いのです。後はssh-addを起動してパスフレーズを入力してもらいます。

ということでcall-process関数を使用して ssh-agent を起動しようとしたのですが、なぜが処理が返ってきません。 (call-process "ssh-agent" nil t) のように書いて評価すると何秒経っても終了しません。C-gで中断することはできます。Windows上で試すとちゃんとすぐに処理が帰ってきます。call-processが必ず帰ってこないわけではありません。 ls のようなものは大丈夫ですし、 ssh-agent -k のようなものもすぐに帰ってきます。

shell-commandもダメでした(&を付けて非同期にするのは試していません)。内部でcall-processを呼んでいるからでしょう。

EShellのeshell-commandはすぐに帰ってきました。とは言えその後 ssh-add するときにキーフレーズを入力する方法が分からず諦めました。

というわけで、非同期的にプロセスを実行するstart-process関数を使って書くことにしました。

(defun my-ssh-setup ()
  "ssh-agentとssh-addを起動します。"
  (interactive)
  (my-ssh-agent-start)
  (my-ssh-add))

(defun my-ssh-call-process (program &optional args body on-exit)
  "PROGRAMを実行して終了するまで待ちます。

ARGSはPROGRAMの引数です。

終了するまでの間にBODYに指定した関数を不定期的に呼び出します。
関数に引き渡される引数はプロセスオブジェクト一つです。

終了した後にON-EXITに指定した関数を呼び出します。
関数に引き渡される引数はPROGRAMの終了ステータスコードです。

BODYやON-EXITを呼び出す際のカレントバッファはプロセスの出力を保持するバッ
ファです。それぞれのタイミングでPROGRAMが出力したテキストを調べることが
出来ます。"
  (let ((buffer (get-buffer-create "*ssh-agent*")))
    (with-current-buffer buffer
      (erase-buffer)
      (let ((process (apply #'start-process
                            program buffer
                            program args)))
        (set-process-sentinel
         process
         (lambda (process _event)
           (when (memq (process-status process)
                       '(exit signal))
             (process-put process :my-ssh-finished t))))
        (while (not (process-get process :my-ssh-finished))
          (when (input-pending-p)
            (discard-input)
            ;; Androidではdiscard-inputしてもread-eventで少し待たない
            ;; とsit-forが即時リターンしてsentinelが呼び出されないこと
            ;; がある。
            (read-event nil nil 0.2))
          (sit-for 0.1) ;; ここでsentinelが呼び出されるかも
          (when body
            (funcall body process)))
        (when on-exit
          (funcall on-exit (process-exit-status process)))
        (process-exit-status process)))))

(defun my-ssh-agent-start ()
  "ssh-agentを開始します。"
  (interactive)
  (if (getenv "SSH_AGENT_PID")
      (message "SSH_AGENT_PID is already set")
    (my-ssh-call-process
     "ssh-agent" nil nil
     (lambda (status)
       (unless (zerop status)
         (error "ssh-agent failed"))
       (let ((vars
              (mapcar
               (lambda (var)
                 (goto-char (point-min))
                 ;; Error if not found
                 (re-search-forward (concat var "=\\([-_a-zA-Z0-9./]+\\)"))
                 (list var (match-string 1)))
               '("SSH_AUTH_SOCK" "SSH_AGENT_PID"))))
         (dolist (vv vars) (apply #'setenv vv))
         (message "ssh-agent vars=%s" vars))))))

(defun my-ssh-agent-stop ()
  "ssh-agentを停止します。"
  (interactive)
  (call-process "ssh-agent" nil nil nil "-k")
  (setenv "SSH_AGENT_PID"))

(defun my-ssh-add ()
  "ssh-addを起動してパスフレーズを入力・記録します。"
  (interactive)
  (let ((read-pos nil))
    (my-ssh-call-process
     "ssh-add" nil
     (lambda (process)
       (save-excursion
         (goto-char (or read-pos (point-min)))
         ;; Enter passphrase ... が現れたらパスフレーズを入力させてそ
         ;; れをプロセスへ送信する。
         (when (re-search-forward "^Enter .*: *" nil t)
           (let ((prompt (match-string 0)))
             (process-send-string
              process
              (concat (read-passwd prompt) "\n")))
           (setq read-pos (point))))))))


;; 以下おまけ

(defun my-ssh-ensure ()
  "環境変数SSH_AGENT_PIDが設定されていなければ`my-ssh-setup'を呼び出します。
設定されているなら何もしません。"
  (interactive)
  (unless (getenv "SSH_AGENT_PID")
    (my-ssh-setup)))

(defun my-ssh-init ()
  "適当なタイミングでパスフレーズを入力するよう準備します。
これをinit.elから呼び出しておけば、MagitやVC、Trampのパスフレーズが必要
そうなタイミングで自動的に`my-ssh-ensure'が呼び出されます。
あまり細かい条件は見ていないので必要に応じて修正してください。"
  ;; Magitのpushまたはpull
  (defun my-ssh-agent-init-on-magit-start-git (_input &rest args)
    (with-demoted-errors "Error my-ssh-agent: %s"
      (when (member (flatten-tree args) '("push" "pull"))
        (my-ssh-ensure))))
  (advice-add #'magit-start-git :before
              #'my-ssh-agent-init-on-magit-start-git)

  ;; VCのgitのpushまたはpull
  (defun my-ssh-agent-init-on-vc-git--pushpull (&rest _)
    (with-demoted-errors "Error my-ssh-agent: %s"
      (my-ssh-ensure)))
  (advice-add 'vc-git--pushpull :before
              #'my-ssh-agent-init-on-vc-git--pushpull)

  ;; Trampのssh等
  (defun my-ssh-agent-init-on-tramp-ssh-controlmaster-options (vec)
    (with-demoted-errors "Error my-ssh-agent: %s"
      (when (seq-find (lambda (hop)
                        (member (tramp-file-name-method hop)
                                '("ssh" "sshx" "scp" "scpx")))
                      (tramp-compute-multi-hops vec))
        (my-ssh-ensure))))
  (advice-add #'tramp-ssh-controlmaster-options :before
              #'my-ssh-agent-init-on-tramp-ssh-controlmaster-options))

少し試した限りうまく行っているようです。

他の方法について。

非同期プロセスの処理を書くのは面倒なのでeshellをうまく利用して何とかならないかなと思ったのですが、私にはあまりうまく出来ませんでした。ssh-agentが出力するスクリプトをそのまま評価できれば良かったのですが。環境変数(process-environment変数)はデフォルトだとバッファローカルになりますがeshell-modify-global-environment変数を変えれば行けそう。最終的にはssh-addの入力部分をどうしたらよいのか分かりませんでした。無理矢理実現するなら大人しく上のように書いてしまった方が良いのかなと。

Magitだけを通すならおそらく magit-process-password-prompt-regexpsmagit-process-find-password-functions をいじれば(そもそもssh-agentを起動しなくても)何とかなりそうな気もします。どこかにパスフレーズを保存しておく方法に限らず、Emacsがssh-agentのようにパスフレーズを一時的に覚えておくことも出来るかもしれません。とは言えsshを使うのがMagitに限らないのであれば、大人しくssh-agentを起動してしまった方が良いでしょう。

AndroidではOSが無駄なプロセスを自動的に削除してしまうことがあるらしいので、そこは注意が必要かもしれません。

2025-03-05

モードラインをドラッグしてウィンドウを消す

Emacsではモードラインをマウスでドラッグすると、分割されているウィンドウのサイズ(境界線)が調整できます(実際にはモードラインに限らずヘッダーライン等でもできるみたいです)。

しかし出来るのはそこまで。フレームの端までドラッグしてもウィンドウが閉じたりはしません。「これ以上リサイズできるウィンドウは無いよ!」といった悲鳴(エラー)が発せられるだけです。

まぁそんなものか……とC-x 1やC-x 0を押せば済む話。……本当に? その時そこにキーボードがあるとは限らないのです。そう、Androidなら。

もちろんHacker's Keyboardを使ってC-x 1と押すことは出来ます。メニューバーのFileを押しメニューを下にスクロールしてRemove Other Windowsを選択しても良いです。ツールバーか何かにウィンドウを閉じるボタンを配置しても良いかもしれません。ちなみにマウスではモードラインを右クリックすればウィンドウが閉じますが、デフォルトのタッチ操作では右クリックは再現できないようです。

でもやっぱり境界のドラッグやスワイプで閉じられた方が自然ではありませんか?

調査と実装

端までドラッグすると次のようなエラーメッセージがログに記録されます。

adjust-window-trailing-edge: No resizable window below this one

adjust-window-trailing-edge という関数が実際にウィンドウのサイズを調整しています。

(defun adjust-window-trailing-edge (window delta &optional horizontal pixelwise)

この関数はウィンドウの右辺あるいは下辺の位置を調整します。

引数windowにドラッグ中のモードラインを持つウィンドウが渡されるようです。deltaはサイズの変化量(正の時は右または下へ移動、負の時は左または上へ移動)。horizontalは非nilの時右辺を移動し、nilの時下辺を移動します。

コードの中身を見ると案外複雑なのですが、これは実際に沢山のウィンドウを並べてからモードラインをドラッグしてみれば納得できるでしょう。

例えば次図で黒い矢印の場所をドラッグしたら、実際には赤い矢印の場所を移動しなければなりません。その下にあるウィンドウがこれ以上小さく出来ない場合はさらに外側(青色)を移動する必要もあります。

このウィンドウのモードラインをドラッグするこのウィンドウの下辺を変更する必要がある

ちなみに、ウィンドウの構造は基本的に次のような状態にはならないようです(同じ向きで入れ子になる状態。垂直方向も同様)。

横並び親も横並び

これは次のような状態になります。おそらく分割や削除を行ったときに不要な包含ウィンドウを削除して中身を展開しているのだと思います。

一つの横並び

モードラインを持つwindowから実際にリサイズするウィンドウを求める処理はadjust-window-trailing-edgeの比較的最初の方にあります。

    ;; Find the edge we want to move.
    (while (and (or (not (window-combined-p right horizontal))
                    (not (window-right right)))
                (setq right (window-parent right))))

注:

  • window-combined-pは、window(ここではright)がhorizontalで指定した方向に並んだウィンドウのうちの一つかどうかを判定します。方向が合わないならnilを返します。
  • window-rightは次の兄弟を返します。rightとありますが下かもしれません。方向は先にwindow-combined-pによって確認しています。window-next-siblingとは違いwindowにはnilを指定出来ず、エコーエリアを返すこともありません。

この処理は後で使うことになるので関数化しておきます。

(defun my-window-right-edge (window horizontal)
  (while (and (or (not (window-combined-p window horizontal))
                  (not (window-right window)))
              (setq window (window-parent window))))
  window)

adjust-window-trailing-edgeはその後実際にリサイズできる余地があるのかを調べるのですが、そこで余地がなければ「これ以上リサイズできない!」と件のエラー(悲鳴)を発するわけです。

なので、この関数の外側でエラーを監視し、そのエラーを検出したらリサイズではなく削除を行ってみてはどうでしょうか。

(defun my-adjust-window-trailing-edge:around (old-fun
                                              window delta
                                              &optional horizontal pixelwise)
  (condition-case err
      ;; 元の関数を呼び出す
      (funcall old-fun window delta horizontal pixelwise)
    ;; user-errorをトラップ
    (user-error
     (pcase (error-message-string err) ;; エラーメッセージで分岐する
       ;; 左または上にリサイズ可能なウィンドウが無い場合
       ((or "No resizable window on the left of this one"
            "No resizable window above this one")
        (delete-window
         ;; 削除するのは境界を持つウィンドウ(WINDOWを含む!)
         (my-window-right-edge window horizontal)))
       ;; 右または下にリサイズ可能なウィンドウが無い場合
       ((or "No resizable window on the right of this one"
            "No resizable window below this one")
        (delete-window
         ;; 削除するのは下または右隣のウィンドウ(WINDOWは維持される)
         (window-right
          (my-window-right-edge window horizontal))))
       ;; その他のエラーは再送
       (_ (signal (car err) (cdr err)))))))

(advice-add #'adjust-window-trailing-edge :around #'my-adjust-window-trailing-edge:around)
;; ↓で解除
;; (advice-remove #'adjust-window-trailing-edge #'my-adjust-window-trailing-edge:around)

これだけでもちゃんとドラッグでウィンドウが消えてくれます。

しかし上や左にドラッグしてウィンドウを消すと、次のようなエラーメッセージが表示されます。

Wrong type argument: window-valid-p, #<window 349>

ウィンドウを削除してもドラッグ状態はまだ続いているので、動かすたびに削除したwindowにアクセスしてこのエラーが発生してしまいます。右や下にドラッグした場合はドラッグ中のwindowは消えないのでエラーは発生しません。

また、下や右へドラッグしたときは継続して複数のウィンドウを消せますが、左や上へドラッグしたときはそれ以上消せません。

この問題を真面目に修正することも可能ですが、元のソースコードを修正せずにadvice等を使って外から直すのは面倒です。煩雑なコードをinit.elに入れるほどの価値は無いと思ったので、適当にエラーを黙らせることにしました(実際には他のelファイルに入っていてinit.elにあるのはautoloadと最初のadvice-addだけですが)。

コード

というわけで最終的なコードは次のようになりました。

;; ドラッグ開始時の処理が呼ばれるようにする。
;; (autoload 'my-mouse-drag-line--begin "my-mouse-drag-line")
(advice-add #'mouse-drag-line :after #'my-mouse-drag-line--begin)

(defun my-mouse-drag-line--begin (&rest _)
  "ドラッグ開始時の処理。"
  ;; ドラッグ中、adjust-window-trailing-edgeの動作を変えて、リサイズで
  ;; きないときは削除する。
  (advice-add #'adjust-window-trailing-edge :around
              #'my-mouse-drag-line--adjust-window-trailing-edge)
  ;; 一時キーマップが終了するタイミングを検出する方法がここくらいしか
  ;; 見当たらなかったので。これがダメならタイマーやpost-command-hookを
  ;; 使うくらいしか?
  (advice-add #'internal-pop-keymap :after
              #'my-mouse-drag-line--end))

(defun my-mouse-drag-line--end (&rest _)
  "ドラッグ終了時の処理。"
  ;; 元に戻す。
  (advice-remove #'adjust-window-trailing-edge
                 #'my-mouse-drag-line--adjust-window-trailing-edge)
  (advice-remove #'internal-pop-keymap
                 #'my-mouse-drag-line--end))

(defun my-window-right-edge (window horizontal)
  "WINDOWと右辺または下辺を共有する一番上のウィンドウを返す。"
  (while (and (or (not (window-combined-p window horizontal))
                  (not (window-right window)))
              (setq window (window-parent window))))
  window)

(defun my-mouse-drag-line--adjust-window-trailing-edge
    (old-fun window delta &optional horizontal pixelwise)
  "ドラッグ中にadjust-window-trailing-edgeが呼ばれたときの処理。
リサイズできなかった場合はウィンドウを削除する。"
  (condition-case err
      (funcall old-fun window delta horizontal pixelwise)
    (user-error
     (pcase (error-message-string err)
       ;; 左または上にリサイズ可能なウィンドウが無い場合。
       ;; ドラッグしている(モード行がある)ウィンドウを削除する。
       ((or "No resizable window on the left of this one"
            "No resizable window above this one")
        (delete-window (my-window-right-edge window horizontal))
        ;; 無理矢理drag-mouse-1イベントを起こして終了させる。
        ;; ここは (funcall (lookup-key overriding-terminal-local-map
        ;; [drag-mouse-1])) とかでも良いのかもしれない。
        ;; mouse-drag-lineを参照。exitfunを呼ぶ方法が限られる。
        (push 'drag-mouse-1 unread-command-events)
        ;; よく分かってないけどnilにしておけばエラーを回避できる。
        ;; (Emacs 30.1以降でタッチスクリーンを使用した場合)
        (setq touch-screen-current-tool nil))

       ;; 右または下にリサイズ可能なウィンドウが無い場合。
       ;; ドラッグしている(モード行がある)ウィンドウの次を削除する。
       ((or "No resizable window on the right of this one"
            "No resizable window below this one")
        (delete-window (window-right
                        (my-window-right-edge window horizontal))))

       ;; その他のエラーは再送する。
       (_ (signal (car err) (cdr err)))))))

;; ドラッグが終了した後にエラーが発生するようなので握りつぶす。
;; あまり常時エラーを握りつぶしたくないけどmouse関連だからまあいいか。
(defun my-mouse-select-window:around (old-fun click)
  (when (window-live-p (posn-window (event-start click)))
    (funcall old-fun click)))
(advice-add #'mouse-select-window :around #'my-mouse-select-window:around)

ちょっと適当な所もありますが、とりあえずマウスによるドラッグでもタッチ操作でもウィンドウを閉じられるようになりました。

タッチ操作の場合は touch-screen.el の作用によって、タッチイベントがマウスイベントに変換されて動作します。ドラッグも再現してくれるおかげでマウス用に書かれたコードがそのままで動作してくれます。ただ、この変換部分にもタッチしたwindowを記録して保持する部分が存在しているため、ドラッグ中に削除するとエラーが発生します。それを抑制しているのが (setq touch-screen-current-tool nil) の部分です。この辺りはあまりちゃんとコードを読んでいないので、操作によっては正しく動かない場合もあるかもしれません。複雑なタッチ操作を行おうとした場合とか? モードラインのドラッグ程度ならおそらく大丈夫だと思いますが。