🍖

cfiler(内骨格)個人用設定まとめ

2024/07/20に公開

最新状況(日々更新中)

https://github.com/AWtnb/cfiler


結構なカスタマイズになったので備忘録として。

以下、基本的に公式のソースを参考に config.py を書いていきます。

https://github.com/crftwr/cfiler

https://github.com/crftwr/ckit

事前準備

各種インポート

コード内で使用するライブラリを読み込んでおきましょう。
コメントで付記しているように、できるだけ型ヒントを書くために(&エディタの警告を減らすために)ソースを参考に追加で色々読み込んでおきます。
cfiler_~~ 関連は何もしなくても読み込まれるので記載しなくても大丈夫ではあります。

import datetime
import hashlib
import inspect
import os
import shutil
import subprocess
from pathlib import Path

from cfiler import *

# 以降は型ヒントのために追記
from typing import Callable
import ckit
import pyauto

from cfiler_mainwindow import (
    MainWindow,
    History,
    PAINT_LEFT_LOCATION,
    PAINT_LEFT_HEADER,
    PAINT_LEFT_ITEMS,
    PAINT_LEFT_FOOTER,
    PAINT_RIGHT_LOCATION,
    PAINT_RIGHT_HEADER,
    PAINT_RIGHT_ITEMS,
    PAINT_RIGHT_FOOTER,
    PAINT_FOCUSED_LOCATION,
    PAINT_FOCUSED_HEADER,
    PAINT_FOCUSED_ITEMS,
    PAINT_FOCUSED_FOOTER,
    PAINT_VERTICAL_SEPARATOR,
    PAINT_LOG,
    PAINT_STATUS_BAR,
    PAINT_LEFT,
    PAINT_RIGHT,
    PAINT_FOCUSED,
    PAINT_UPPER,
    PAINT_ALL,
)

from cfiler_filelist import FileList, item_Base, lister_Default, item_Empty
from cfiler_listwindow import ListWindow
from cfiler_msgbox import popMessageBox, MessageBox
from cfiler_misc import candidate_Filename

グローバル変数の宣言

後述するように、内骨格は画面を再描画する際にMainWindowクラスの paint() メソッドを実行しています。
ソースを参考に各種オプションを指定できるよう、事前にグローバル変数を作っておきます。

このとき一緒にユーザー名や改行コードなども控えています。

class PaintOption:
    LeftLocation = PAINT_LEFT_LOCATION
    LeftHeader = PAINT_LEFT_HEADER
    LeftItems = PAINT_LEFT_ITEMS
    LeftFooter = PAINT_LEFT_FOOTER
    RightLocation = PAINT_RIGHT_LOCATION
    RightHeader = PAINT_RIGHT_HEADER
    RightItems = PAINT_RIGHT_ITEMS
    RightFooter = PAINT_RIGHT_FOOTER
    FocusedLocation = PAINT_FOCUSED_LOCATION
    FocusedHeader = PAINT_FOCUSED_HEADER
    FocusedItems = PAINT_FOCUSED_ITEMS
    FocusedFooter = PAINT_FOCUSED_FOOTER
    VerticalSeparator = PAINT_VERTICAL_SEPARATOR
    Log = PAINT_LOG
    StatusBar = PAINT_STATUS_BAR
    Left = PAINT_LEFT
    Right = PAINT_RIGHT
    Focused = PAINT_FOCUSED
    Upper = PAINT_UPPER
    All = PAINT_ALL


PO = PaintOption()

USER_PROFILE = os.environ.get("USERPROFILE") or ""
LINE_BREAK = os.linesep

configure()

基本的な設定は configure() 関数内に書いていきます。
以下、インデントは省略していますのでコピペされる際はご注意ください。

def configure(window: MainWindow) -> None:
    # (ここに色々書いていく)
    # ︙

デフォルトのキーバインドを上書き

デフォルトだとピリオドキーにミュージックプレーヤー機能が割り当てられています。個人的には使用しない機能なので無効化しておきます。

辞書登録の方式でキー入力と関数を対応させます。lambda _: None としているのは、キー押しによって関数が実行される際に引数として ckit.ckit_command.CommandInfo オブジェクトが渡されるためです。これは基本的に使用しないので _ で受けておきます。

def reset_default_keys(keys: list) -> None:
    for key in keys:
        window.keymap[key] = lambda _: None

reset_default_keys(
    [
        "Period",
        "S-Period",
    ]
)

次いで、用意されている関数をループで割り当てておきます。

def apply_cfiler_command(mapping: dict) -> None:
    for key, func in mapping.items():
        window.keymap[key] = func

apply_cfiler_command(
    {
        # 終了系
        "C-S-Q": window.command_CancelTask,
        "C-Q": window.command_Quit,
        "A-F4": window.command_Quit,
        # 設定メニュー
        "C-Comma": window.command_ConfigMenu,
        "C-S-Comma": window.command_ConfigMenu2,
        # カーソル移動
        "A": window.command_CursorTop,
        "E": window.command_CursorBottom,
        "Home": window.command_CursorTop,
        "End": window.command_CursorBottom,
        "J": window.command_CursorDown,
        "K": window.command_CursorUp,
        "C-Up": window.command.CursorUpSelectedOrBookmark,
        "C-K": window.command.CursorUpSelectedOrBookmark,
        "C-Down": window.command.CursorDownSelectedOrBookmark,
        "C-J": window.command.CursorDownSelectedOrBookmark,
        # その他ファイラ操作
        "A-C-H": window.command_JumpHistory,
        "Back": window.command_JumpHistory,
        "H": window.command_GotoParentDir,
        "N": window.command_Rename,
        "C-D": window.command_Delete,
        "P": window.command_FocusOther,
        "C-L": window.command_FocusOther,
        "O": window.command_ChdirActivePaneToOther,
        "S-O": window.command_ChdirInactivePaneToOther,
        "C-S-P": window.command_CommandLine,
        "C-S-N": window.command_Mkdir,
        "A-C": window.command_ContextMenu,
        "A-S-C": window.command_ContextMenuDir,
        "C-N": window.command_DuplicateCfiler,
    }
)

ペイン操作用のクラス

ペイン操作のために CPane というクラスを作っておきます。

クラス全容(巨大化してしまったので折りたたみ)
class CPane:
    def __init__(self, window: MainWindow, active: bool = True) -> None:
        self._window = window
        if active:
            self._pane = self._window.activePane()
            self._items = self._window.activeItems()
        else:
            self._pane = self._window.inactivePane()
            self._items = self._window.inactiveItems()

    @property
    def entity(self):
        return self._pane

    def repaint(self, option: PaintOption = PO.All) -> None:
        self._window.paint(option)

    def refresh(self) -> None:
        self._window.subThreadCall(self.fileList.refresh, ())
        self.fileList.applyItems()

    @property
    def items(self) -> list:
        return self._items

    @property
    def dirs(self) -> list:
        items = []
        for i in range(self.count):
            item = self.byIndex(i)
            if item.isdir():
                items.append(item)
        return items

    @property
    def files(self) -> list:
        items = []
        for i in range(self.count):
            item = self.byIndex(i)
            if not item.isdir():
                items.append(item)
        return items

    @property
    def history(self) -> History:
        return self._pane.history

    def appendHistory(self, path: str) -> History:
        p = Path(path)
        lister = self.lister
        visible = isinstance(lister, lister_Default)
        return self._pane.history.append(str(p.parent), p.name, visible, False)

    @property
    def cursor(self) -> int:
        return self._pane.cursor

    def focus(self, i: int) -> None:
        self._pane.cursor = i
        self.scrollToCursor()

    def byName(self, name: str) -> int:
        return self.fileList.indexOf(name)

    def hasName(self, name: str) -> bool:
        return self.byName(name) != -1

    def focusByName(self, name: str) -> None:
        i = self.byName(name)
        if i < 0:
            return
        self.focus(i)

    def focusOther(self) -> None:
        self._window.command_FocusOther(None)

    @property
    def fileList(self) -> FileList:
        return self._pane.file_list

    @property
    def lister(self):
        return self.fileList.getLister()

    @property
    def hasSelection(self) -> bool:
        return self.fileList.selected()

    @property
    def scrollInfo(self) -> ckit.ScrollInfo:
        return self._pane.scroll_info

    @property
    def currentPath(self) -> str:
        return self.fileList.getLocation()

    @property
    def count(self) -> int:
        return self.fileList.numItems()

    def byIndex(self, i: int) -> item_Base:
        return self.fileList.getItem(i)

    @property
    def isBlank(self) -> bool:
        return isinstance(self.byIndex(0), item_Empty)

    @property
    def names(self) -> list:
        names = []
        for i in range(self.count):
            item = self.byIndex(i)
            names.append(item.getName())
        return names

    @property
    def extensions(self) -> list:
        exts = []
        for i in range(self.count):
            path = Path(self.pathByIndex(i))
            ext = path.suffix.replace(".", "")
            if path.is_file() and ext not in exts:
                exts.append(ext)
        return exts

    @property
    def selectedItems(self) -> list:
        items = []
        for i in range(self.count):
            item = self.byIndex(i)
            if item.selected():
                items.append(item)
        return items

    @property
    def selectedItemPaths(self) -> list:
        paths = []
        for i in range(self.count):
            item = self.byIndex(i)
            if item.selected():
                path = self.pathByIndex(i)
                paths.append(path)
        return paths

    @property
    def focusedItem(self) -> item_Base:
        return self.byIndex(self.cursor)

    def pathByIndex(self, i: int) -> str:
        item = self.byIndex(i)
        return str(Path(self.currentPath, item.getName()))

    @property
    def focusedItemPath(self) -> str:
        return self.pathByIndex(self.cursor)

    def finishSelect(self) -> None:
        self.repaint(PO.FocusedItems | PO.FocusedHeader)

    def toggleSelect(self, i: int) -> None:
        self.fileList.selectItem(i, None)
        self.finishSelect()

    def select(self, i: int) -> None:
        self.fileList.selectItem(i, True)
        self.finishSelect()

    def selectByName(self, name: str) -> None:
        i = self.byName(name)
        if i < 0:
            return
        self.fileList.selectItem(i, True)
        self.finishSelect()

    def unSelect(self, i: int) -> None:
        self.fileList.selectItem(i, False)
        self.finishSelect()

    @property
    def selectionTop(self) -> int:
        for i in range(self.count):
            if self.byIndex(i).selected():
                return i
        return -1

    @property
    def selectionBottom(self) -> int:
        idxs = []
        for i in range(self.count):
            if self.byIndex(i).selected():
                idxs.append(i)
        if len(idxs) < 1:
            return -1
        return idxs[-1]

    def scrollTo(self, i: int) -> None:
        self.scrollInfo.makeVisible(i, self._window.fileListItemPaneHeight(), 1)
        self.repaint(PO.FocusedItems)

    def scrollToCursor(self) -> None:
        self.scrollTo(self.cursor)

    def openPath(self, path: str):
        target = Path(path)
        if not target.exists():
            print("invalid path: '{}'".format(path))
            return
        focus_name = None
        if target.is_file():
            path = str(target.parent)
            focus_name = target.name
        lister = lister_Default(self._window, path)
        self._window.jumpLister(self._pane, lister, focus_name)

    def touch(self, name: str) -> None:
        if not hasattr(self.lister, "touch"):
            print("cannot make file here.")
            return
        dp = Path(self.currentPath, name)
        if dp.exists() and dp.is_file():
            print("file '{}' already exists.".format(name))
            return
        self._window.subThreadCall(self.lister.touch, (name,))
        self.refresh()
        self.focus(self._window.cursorFromName(self.fileList, name))

    def mkdir(self, name: str, focus: bool = True) -> None:
        if not hasattr(self.lister, "mkdir"):
            print("cannot make directory here.")
            return
        dp = Path(self.currentPath, name)
        if dp.exists() and dp.is_dir():
            print("directory '{}' already exists.".format(name))
            return
        self._window.subThreadCall(self.lister.mkdir, (name, None))
        self.refresh()
        if focus:
            self.focus(self._window.cursorFromName(self.fileList, name))

クラスを継承させて、左右ペインそれぞれのためのサブクラスも作っておきます。

class LeftPane(CPane):
    def __init__(self, window: MainWindow) -> None:
        super().__init__(window, (window.focus == MainWindow.FOCUS_LEFT))

    def activate(self) -> None:
        if self._window.focus == MainWindow.FOCUS_RIGHT:
            self._window.focus = MainWindow.FOCUS_LEFT
        self.repaint(PO.Left | PO.Right)

class RightPane(CPane):
    def __init__(self, window: MainWindow) -> None:
        super().__init__(window, (window.focus == MainWindow.FOCUS_RIGHT))

    def activate(self) -> None:
        if self._window.focus == MainWindow.FOCUS_LEFT:
            self._window.focus = MainWindow.FOCUS_RIGHT
        self.repaint(PO.Left | PO.Right)

自作関数への割り当て

前述のように、キー入力に関数を対応させるには、引数として渡されることになる ckit.ckit_command.CommandInfo の扱いに注意が必要でした。

この部分を気にせずにキーバインドを設定できるように、クラスを作っておきます。

渡された自作関数が ckit.ckit_command.CommandInfo を引数として受け取るかどうか判定したうえでラッパー関数を作ってキー入力に対応させます。

class Keybinder:
    def __init__(self, window: MainWindow) -> None:
        self._window = window

    @staticmethod
    def wrap(func: Callable) -> Callable:
        if inspect.signature(func).parameters.items():

            def _callback_with_arg(arg):
                # arg には ckit.ckit_command.CommandInfo が渡される
                func(arg)

            return _callback_with_arg

        def _callback(_):
            # 渡された引数は無視して関数を実行する
            func()

        return _callback

    def bind(self, key: str, func: Callable) -> None:
        self._window.keymap[key] = self.wrap(func)

    def bindmulti(self, mapping: dict) -> None:
        for key, func in mapping.items():
            self._window.keymap[key] = self.wrap(func)

KEYBINDER = Keybinder(window)

これで、 KEYBINDER.bind((キー), (関数)) と指定することで簡単にキーバインドに割り当てられるようになります(bindmulti を使って一括で割り当てることも可能)。

よしなにEnter

Lキーを押したときに、フォルダにフォーカスしていればそのフォルダを開き、ファイルにフォーカスしていれば既定のアプリで開きます(フォルダが空ならペイン移動)。
これによってナビゲーションがHJKLで完結します。

def smart_enter():
    pane = CPane(window)
    if pane.isBlank:
        pane.focusOther()
        return
    if pane.focusedItem.isdir():
        window.command_Enter(None)
    else:
        window.command_Execute(None)

KEYBINDER.bind("L", smart_enter)

ヘルプを開く

def open_doc():
    help_path = str(Path(ckit.getAppExePath(), "doc", "index.html"))
    shell_exec(help_path)

KEYBINDER.bind("A-H", open_doc)

設定ファイルのあるフォルダをVSCodeで開く

cfilerというフォルダをgitで管理し、その中の config.py のシンボリックリンクを cfiler.exe 本体と同フォルダに作っています。

def edit_config():
    dir_path = Path(USER_PROFILE, r"develop\repo\cfiler")
    if dir_path.exists():
        dp = str(dir_path)
        vscode_path = Path(USER_PROFILE, r"scoop\apps\vscode\current\Code.exe")
        if vscode_path.exists():
            vp = str(vscode_path)
            shell_exec(vp, dp)
        else:
            shell_exec(dp)
    else:
        shell_exec(USER_PROFILE)
        print("cannot find repo dir. open user profile instead.")

KEYBINDER.bind("C-E", edit_config)

設定ファイルのリロード

config.py への変更を反映させて、ついでにペイン区切りの位置を中央に戻し、左ペインにフォーカス。

def reload_config():
    window.configure()
    window.command_MoveSeparatorCenter(None)
    LeftPane(window).activate()
    ts = datetime.datetime.today().strftime("%Y-%m-%d %H:%M:%S")
    print("{} reloaded config.py\n".format(ts))

KEYBINDER.bind("C-R", reload_config)
KEYBINDER.bind("F5", reload_config)

左右ペインの入れ替え

ちょっとしたことですが便利です。

def swap_pane() -> None:
    pane = CPane(window, True)
    current_path = pane.currentPath
    other_pane = CPane(window, False)
    other_path = other_pane.currentPath
    pane.openPath(other_path)
    other_pane.openPath(current_path)

KEYBINDER.bind("A-S", swap_pane)

すばやくコピー/移動

非選択状態でもコピー/移動をすぐに実行できるようにしています。

def quick_move() -> None:
    pane = CPane(window)
    if not pane.fileList.selected():
        window.command_Select(None)
    window.command_Move(None)
    pane.focusOther()

KEYBINDER.bind("M", quick_move)

def quick_copy() -> None:
    pane = CPane(window)
    if not pane.fileList.selected():
        window.command_Select(None)
    window.command_Copy(None)
    pane.focusOther()

KEYBINDER.bind("C", quick_copy)

ジャンプリスト拡張

コードはソースのほぼ丸写しです。
登録したディレクトリをリストウィンドウから選択して、決定時にShiftを押していれば別のペインで開くのが工夫ポイント。

class JumpList:
    def __init__(self, window: MainWindow) -> None:
        self._window = window

    def update(self, jump_table: dict) -> None:
        for name, path in jump_table.items():
            p = Path(path)
            try:
                if p.exists():
                    self._window.jump_list += [(name, str(p))]
            except Exception as e:
                print(e)

    def jump(self) -> None:

        wnd = self._window
        pos = wnd.centerOfFocusedPaneInPixel()
        list_window = ListWindow(
            x=pos[0],
            y=pos[1],
            min_width=40,
            min_height=1,
            max_width=wnd.width() - 5,
            max_height=wnd.height() - 3,
            parent_window=wnd,
            ini=wnd.ini,
            title="Jump (other pane with Shift)",
            items=wnd.jump_list,
            initial_select=0,
            onekey_search=False,
            onekey_decide=False,
            return_modkey=True,
            keydown_hook=None,
            statusbar_handler=None,
        )
        wnd.enable(False)
        list_window.messageLoop()
        result, mod = list_window.getResult()
        wnd.enable(True)
        wnd.activate()
        list_window.destroy()

        if result < 0:
            return

        dest = wnd.jump_list[result][1]
        active = CPane(wnd, True)
        other = CPane(wnd, False)
        if mod == ckit.MODKEY_SHIFT:
            other.openPath(dest)
            active.focusOther()
        else:
            active.openPath(dest)


JUMP_LIST = JumpList(window)

JUMP_LIST.update(
    {
        "Desktop": str(Path(USER_PROFILE, "Desktop")),
        "Project": str(Path(USER_PROFILE, "Project")),
    }
)

KEYBINDER.bind("C-Space", JUMP_LIST.jump)

以下、exeを直に実行することもあるので関数を作っておきます。

def shell_exec(path: str, *args) -> bool:
    if type(path) is not str:
        path = str(path)
    if not Path(path).exists():
        print("invalid path: '{}'".format(path))
        return False
    params = []
    for arg in args:
        if len(arg.strip()):
            if " " in arg:
                params.append('"{}"'.format(arg))
            else:
                params.append(arg)
    pyauto.shellExecute(None, path, " ".join(params), "")
    return True

Enterフック拡張

Enterを押すと、デフォルトだとファイルの中身(テキストやバイナリ情報)が表示されます。
htmlなどのテキストファイルであれば便利ですが、PDFやMicrosoft Word、Microsoft Excelはさすがに直に実行したいので動作をカスタマイズします。

window.enter_hook に渡される関数が True を返せばデフォルトの挙動がキャンセルされるそうです。

def hook_enter() -> None:
    pane = CPane(window)
    p = pane.focusedItem.getFullpath()
    ext = Path(p).suffix

    if ext == ".pdf":
        sumatra_path = Path(
            USER_PROFILE, r"AppData\Local\SumatraPDF\SumatraPDF.exe"
        )
        if sumatra_path.exists():
            return shell_exec(str(sumatra_path), p)

    if ext in [".xlsx", ".xls"]:
        excel_path = Path(
            r"C:\ProgramData\Microsoft\Windows\Start Menu\Programs\Excel.lnk"
        )
        if excel_path.exists():
            return shell_exec(str(excel_path), p)

    if ext in [".docx", ".doc"]:
        word_path = Path(
            r"C:\ProgramData\Microsoft\Windows\Start Menu\Programs\Word.lnk"
        )
        if word_path.exists():
            return shell_exec(str(word_path), p)

    return False

window.enter_hook = hook_enter

zipファイルの展開

内骨格で圧縮ファイルを展開すると、自動で隣のペインが展開先になります。
うっかり共有フォルダなどに展開してしまうと危ないので、展開用の一時フォルダを作成&隣のペインで開き、そこに展開するようにしています。

def smart_extract():
    active_pane = CPane(window)
    if len(active_pane.selectedItems) < 1:
        return

    extractable = True
    for p in active_pane.selectedItemPaths:
        if Path(p).suffix != ".zip":
            extractable = False

    if extractable:
        outdir = datetime.datetime.today().strftime("unzip_%Y%m%d%H%M%S")
        active_pane.mkdir(outdir, False)
        inactive_pane = CPane(window, False)
        inactive_pane.openPath(str(Path(active_pane.currentPath, outdir)))
        window.command_ExtractArchive(None)
        active_pane.focusOther()

KEYBINDER.bind("A-S-T", smart_extract)

ごみ箱を開く

意外に使います。

def recylcebin():
    pyauto.shellExecute(None, "shell:RecycleBinFolder", "", "")

KEYBINDER.bind("C-Z ", recylcebin)

現在のパスをコピー

def copy_current_path():
    pane = CPane(window)
    p = pane.currentPath
    ckit.setClipboardText(p)
    window.setStatusMessage("copied current path: '{}'".format(p), 3000)

KEYBINDER.bind("C-A-P", copy_current_path)

エクスプローラで開く

やっぱり何だかんだ使います。

def open_on_explorer():
    pane = CPane(window, True)
    shell_exec(pane.currentPath)

KEYBINDER.bind("C-S-E", open_on_explorer)

VSCodeで開く

def on_vscode():
    vscode_path = Path(USER_PROFILE, r"scoop\apps\vscode\current\Code.exe")
    if vscode_path.exists():
        pane = CPane(window)
        shell_exec(str(vscode_path), pane.currentPath)

KEYBINDER.bind("V", on_vscode)

隣のペインに開く

# 選択している項目を隣に開く
def open_to_other():
    active_pane = CPane(window, True)
    inactive_pane = CPane(window, False)
    inactive_pane.openPath(active_pane.focusedItemPath)
    active_pane.focusOther()

KEYBINDER.bind("S-L", open_to_other)

# 親フォルダを隣に開く
def open_parent_to_other():
    active_pane = CPane(window, True)
    parent = str(Path(active_pane.currentPath).parent)
    current_name = str(Path(active_pane.currentPath).name)
    inactive_pane = CPane(window, False)
    inactive_pane.openPath(parent)
    active_pane.focusOther()
    inactive_pane.focusByName(current_name)

KEYBINDER.bind("S-U", open_parent_to_other)

名前を変えてコピー

内骨格のコピー操作は基本的にペイン間(or サブフォルダへ)のものとして設計されていて、組み込みのコマンドは同フォルダ内の複製に不向きです。

コピー自体はshutil置き換えられますが、JobItemを使っているわけではないので途中でキャンセルできないのは要注意です。

# 止めどないコピー!
def unstoppable_copy(src_path: str, new_path: str) -> None:
    pane = CPane(window)

    def _copy(_) -> None:
        s = Path(src_path)
        if s.is_dir():
            shutil.copytree(src_path, new_path)
        else:
            shutil.copy(src_path, new_path)

    def _finished(_) -> None:
        pane.refresh()
        pane.focusByName(Path(new_path).name)

    job = ckit.JobItem(_copy, _finished)
    window.taskEnqueue(job, create_new_queue=False)

def duplicate_file():
    pane = CPane(window)
    src_path = Path(pane.focusedItemPath)
    result = window.commandLine(
        title="NewName",
        text=src_path.name,
        selection=[len(src_path.stem), len(src_path.stem)],
    )

    if result:
        result = result.strip()
        if len(result) < 1:
            return
        new_path = src_path.with_name(result)
        if new_path.exists():
            print("same item exists!")
            return
        unstoppable_copy(str(src_path), new_path)

KEYBINDER.bind("S-D", duplicate_file)

テキストファイルを簡単に新規作成

メモ取りなどに。invoke() で関数を返すようにしているのがポイントです。

class TextFileMaker:
    def __init__(self, window: MainWindow) -> None:
        self._window = window

    def invoke(self, extension: str = "") -> None:
        def _func() -> None:
            pane = CPane(self._window)
            if not hasattr(pane.fileList.getLister(), "touch"):
                return

            prompt = "NewFileName"
            if 0 < len(extension):
                prompt = prompt + " (.{})".format(extension)
            result = window.commandLine(prompt)
            if not result:
                return
            filename = result.strip()
            if len(filename) < 1:
                return
            if 0 < len(extension):
                filename = filename + "." + extension
            if Path(pane.currentPath, filename).exists():
                print("'{}' already exists.".format(filename))
                return
            pane.touch(filename)

        return _func

TEXT_FILE_MAKER = TextFileMaker(window)

KEYBINDER.bind("T", TEXT_FILE_MAKER.invoke("txt"))
KEYBINDER.bind("A-T", TEXT_FILE_MAKER.invoke("md"))
KEYBINDER.bind("S-T", TEXT_FILE_MAKER.invoke(""))

使わないファイルを _obsolete フォルダへ移動させる

ごみ箱に捨てるほどではないけど…というときに便利です。

def to_obsolete_dir():
    pane = CPane(window)

    items = []
    for i in range(pane.count):
        item = pane.byIndex(i)
        if item.selected() and hasattr(item, "delete"):
            items.append(item)
    if len(items) < 1:
        return

    dest_name = "_obsolete"
    pane.mkdir(dest_name, False)

    child_lister = pane.lister.getChild(dest_name)
    window._copyMoveCommon(
        pane.entity,
        pane.lister,
        child_lister,
        items,
        "m",
        pane.fileList.getFilter(),
    )
    child_lister.destroy()

KEYBINDER.bind("A-O", to_obsolete_dir)

fzf連携

有名なfuzzy finderのfzfを組み込んで、事前に作っておいた命名規則に従って素早くフォルダを作成できるようにしています。

クラスを作ったら長くなったので折りたたみ
class DirRule:
    def __init__(self, current_path: str, src_name: str = ".dirnames") -> None:
        self._current_path = current_path
        self._src_name = src_name

    # 現在のディレクトリから親ディレクトリ、そのまた親ディレクトリ、……と順次ソースファイル(`.dirnames`)を探す。
    # 見つかったらその内容を返す。
    def read_src(self) -> str:
        p = Path(self._current_path)
        depth = len(p.parents) + 1
        for _ in range(depth):
            f = Path(p, self._src_name)
            if f.exists():
                return f.read_text("utf-8")
            p = p.parent
        print("src file '{}' not found...".format(self._src_name))
        return ""

    # ソースファイルの内容をfzfで選択する
    def fzf(self) -> str:
        src = self.read_src().strip()
        if len(src) < 1:
            return ""
        try:
            proc = subprocess.run(
                "fzf.exe", input=src, capture_output=True, encoding="utf-8"
            )
            result = proc.stdout.strip()
            if proc.returncode == 0:
                return result
        except Exception as e:
            print(e)
        return ""

    # アクティブなペインで 0_ や 010- などインデックスが振られているフォルダを探し、その「次」のインデックスを返す。
    def get_index(self) -> str:
        idxs = []
        width = 1
        pane = CPane(window)
        reg = re.compile(r"^\d+")
        for d in pane.dirs:
            name = d.getName()
            if m := reg.match(name):
                s = m.group(0)
                idxs.append(int(s))
                width = max(width, len(s))
        if len(idxs) < 1:
            return "0"
        idxs.sort()
        fmt = "{:0" + str(width) + "}"
        return fmt.format(idxs[-1] + 1)

    # もしfzfで選択した行に `|` があればそれ以降を削除する。
    # もし `#` から始まればそこにインデックスを振る。
    def get_name(self) -> str:
        line = self.fzf()
        if -1 < (i := line.find("|")):
            line = line[:i].strip()
        if len(line) < 1:
            return ""
        if line.startswith("#"):
            idx = self.get_index()
            return idx + line[1:]
        return line

def ruled_mkdir():
    pane = CPane(window)
    dr = DirRule(pane.currentPath)
    name = dr.get_name()
    if 0 < len(name):
        pane.mkdir(name)

KEYBINDER.bind("A-N", ruled_mkdir)

命名規則を適用したいフォルダに .dirnames というテキストファイルをUTF8で作っておきます。

中身の例は以下のようなもので、各行がフォルダ名のテンプレートになります。

  • 先頭が # から始まるものは下記の基準で連番インデックスを振る
    1. 現在のフォルダ内にインデックスが見当たらなければ0を振る
    2. 連番でインデックスされたフォルダがあれば、その「次」の番号でインデックスを振る
  • fzfで選択しやすいように | 以降に検索用のエイリアスを指定する
    • | 以降はフォルダ作成時には無視する(そもそもフォルダ名に使えないため)
#_初校|shoko
#_再校|saiko
#_三校|sanko
appendix_付き物
letter_手紙

command_JumpInput 拡張

これまたほぼソースの写経です。選択肢の決定時にShiftを押していた場合に隣のペインに開くようにしています。

def smart_jump_input():
    pane = CPane(window)
    result, mod = window.commandLine(
        title="JumpInputSmart",
        auto_complete=True,
        autofix_list=["/", "\\"],
        candidate_handler=candidate_Filename(pane.fileList.getLocation()),
        return_modkey=True,
    )
    if result == None:
        return
    result = result.strip()
    if len(result) < 1:
        return
    open_path = Path(pane.currentPath, result)
    if not open_path.exists():
        print("invalid-path!")
        return
    if mod == ckit.MODKEY_SHIFT:
        CPane(window, False).openPath(str(open_path))
        pane.focusOther()
    else:
        pane.openPath(str(open_path))

KEYBINDER.bind("F", smart_jump_input)

選択カスタマイズ

(ファイル|フォルダ)のみ(選択|選択解除)できるようにしたり、拡張子やファイル名指定で選択できるようにしたり、あれこれ詰め込んだ巨大なクラスを作りました。

クラス全容(長いので折りたたみ)
class Selector:
    def __init__(self, window: MainWindow, active: bool = True) -> None:
        self._window = window
        self._active = active

    @property
    def pane(self) -> CPane:
        return CPane(self._window, self._active)

    def allItems(self) -> None:
        pane = self.pane
        for i in range(pane.count):
            pane.select(i)

    def allFiles(self) -> None:
        pane = self.pane
        self.clearAll()
        idx = []
        for i in range(pane.count):
            if not pane.byIndex(i).isdir():
                pane.select(i)
                idx.append(i)
        if 0 < len(idx):
            pane.focus(idx[0])

    def allDirs(self) -> None:
        pane = self.pane
        self.clearAll()
        idx = []
        for i in range(pane.count):
            if pane.byIndex(i).isdir():
                pane.select(i)
                idx.append(i)
        if 0 < len(idx):
            pane.focus(idx[-1])

    def clearAll(self) -> None:
        pane = self.pane
        for i in range(pane.count):
            pane.unSelect(i)

    def clearFiles(self) -> None:
        pane = self.pane
        for i in range(pane.count):
            if pane.byIndex(i).isdir():
                pane.unSelect(i)

    def clearDirs(self) -> None:
        pane = self.pane
        for i in range(pane.count):
            if not pane.byIndex(i).isdir():
                pane.unSelect(i)

    def byFunction(self, func: Callable, unselect: bool = False) -> None:
        pane = self.pane
        if not unselect:
            self.clearAll()
        idx = []
        for i in range(pane.count):
            path = pane.pathByIndex(i)
            if func(path):
                if unselect:
                    pane.unSelect(i)
                else:
                    pane.select(i)
                    idx.append(i)
        if not unselect and 0 < len(idx):
            pane.focus(idx[0])

    def byExtension(self, s: str, unselect: bool = False) -> None:
        def _checkPath(path: str) -> bool:
            return Path(path).suffix == s

        self.byFunction(_checkPath, unselect)

    def stemContains(self, s: str, unselect: bool = False) -> None:
        def _checkPath(path: str) -> bool:
            return s in Path(path).stem

        self.byFunction(_checkPath, unselect)

    def stemStartsWith(self, s: str, unselect: bool = False) -> None:
        def _checkPath(path: str) -> bool:
            return Path(path).stem.startswith(s)

        self.byFunction(_checkPath, unselect)

    def stemEndsWith(self, s: str, unselect: bool = False) -> None:
        def _checkPath(path: str) -> bool:
            return Path(path).stem.endswith(s)

        self.byFunction(_checkPath, unselect)

    def toTop(self) -> None:
        pane = self.pane
        for i in range(pane.count):
            if i <= pane.cursor:
                pane.select(i)

    def toEnd(self) -> None:
        pane = self.pane
        for i in range(pane.count):
            if pane.cursor <= i:
                pane.select(i)

SELECTOR = Selector(window)

KEYBINDER.bindmulti(
    {
        "C-A": SELECTOR.allItems,
        "U": SELECTOR.clearAll,
        "A-F": SELECTOR.allFiles,
        "A-S-F": SELECTOR.clearDirs,
        "A-D": SELECTOR.allDirs,
        "A-S-D": SELECTOR.clearFiles,
        "S-Home": SELECTOR.toTop,
        "S-A": SELECTOR.toTop,
        "S-End": SELECTOR.toEnd,
        "S-E": SELECTOR.toEnd,
    }
)

選択範囲内でカーソルをジャンプさせる

複数選択時に、選択ブロックの端や次の選択ブロックまで素早くカーソルを動かせるようにもしています。

クラス全容(これまた長いので折りたたみ)
class SelectionBlock:
    def __init__(self, window: MainWindow) -> None:
        self._window = window

    @property
    def pane(self) -> CPane:
        return CPane(self._window)

    def topOfCurrent(self) -> int:
        pane = self.pane
        if pane.cursor == 0 or not pane.focusedItem.selected():
            return -1
        for i in reversed(range(0, pane.cursor)):
            if i == pane.selectionTop:
                return i
            if not pane.byIndex(i).selected():
                return i + 1
        return -1

    def bottomOfCurrent(self) -> int:
        pane = self.pane
        if not pane.focusedItem.selected():
            return -1
        for i in range(pane.cursor + 1, pane.count):
            if i == pane.selectionBottom:
                return i
            if not pane.byIndex(i).selected():
                return i - 1
        return -1

    def topOfNext(self) -> int:
        pane = self.pane
        for i in range(pane.cursor + 1, pane.count):
            if pane.byIndex(i).selected():
                return i
        return -1

    def bottomOfPrevious(self) -> int:
        pane = self.pane
        for i in reversed(range(0, pane.cursor)):
            if pane.byIndex(i).selected():
                return i
        return -1

    def jumpDown(self) -> None:
        pane = self.pane
        if pane.cursor == pane.count - 1:
            return
        below = pane.byIndex(pane.cursor + 1)
        dest = -1
        if pane.focusedItem.selected():
            if below.selected():
                dest = self.bottomOfCurrent()
            else:
                dest = self.topOfNext()
        else:
            if below.selected():
                dest = pane.cursor + 1
            else:
                dest = self.topOfNext()
        if dest < 0:
            dest = pane.count - 1
        pane.focus(dest)
        pane.scrollToCursor()

    def jumpUp(self) -> None:
        pane = self.pane
        if pane.cursor == 0:
            return
        above = pane.byIndex(pane.cursor - 1)
        dest = -1
        if pane.focusedItem.selected():
            if above.selected():
                dest = self.topOfCurrent()
            else:
                dest = self.bottomOfPrevious()
        else:
            if above.selected():
                dest = pane.cursor - 1
            else:
                dest = self.bottomOfPrevious()
        if dest < 0:
            dest = 0
        pane.focus(dest)
        pane.scrollToCursor()

SELECTION_BLOCK = SelectionBlock(window)
KEYBINDER.bind("A-J", SELECTION_BLOCK.jumpDown)
KEYBINDER.bind("A-K", SELECTION_BLOCK.jumpUp)

各種コマンドの登録

キー入力で実行する以外にも、window.launcher.command_list に登録することでVSCodeのコマンドパレットのように関数を実行できるようになります。

このような感じに関数を作って登録しています。

def update_command_list(command_table: dict) -> None:
    for name, func in command_table.items():
        window.launcher.command_list += [(name, Keybinder.wrap(func))]

update_command_list(
    {
        "Diffinity": diffinity,
        "RenamePseudoVoicing": rename_pseudo_voicing,
        "SelectNameUnique": select_name_unique,
        "SelectNameCommon": select_name_common,
        "SelectStemStartsWith": select_stem_startswith,
        "SelectStemEndsWith": select_stem_endsswith,
        "SelectStemContains": select_stem_contains,
        "SelectByExtension": select_byext,
        "CompareFileHash": compare_file_hash,
    }
)

各関数の説明は以下。

Diffinityで比較

https://www.truehumandesign.se/s_diffinity.php

とても便利! scoop でインストールできます。左右ペインで1つずつ選択して比較する想定です。

def diffinity():
    exe_path = Path(USER_PROFILE, r"scoop\apps\diffinity\current\Diffinity.exe")
    if not exe_path.exists():
        print("cannnot find diffinity.exe...")
        return

    left_pane = LeftPane(window)
    left_selcted = left_pane.selectedItemPaths
    if len(left_selcted) != 1:
        print("select just 1 file on left pane.")
        return
    left_path = Path(left_selcted[0])
    if not left_path.is_file():
        print("selected item on left pane is not comparable.")
        return
    left_pane = LeftPane(window)

    right_pane = RightPane(window)
    right_selcted = right_pane.selectedItemPaths
    if len(right_selcted) != 1:
        print("select just 1 file on right pane.")
        return
    right_path = Path(right_selcted[0])
    if not right_path.is_file():
        print("selected item on right pane is not comparable.")
        return

    shell_exec(exe_path, str(left_path), str(right_path))

分離した濁点・半濁点を修正

いわゆるNFD問題。
内骨格の表示にプログラミング用フォント(最近は UDEV Gothic)を指定していれば視覚的には気がつきますが、修正が地味に手間なので変換コマンドを。

class PseudoVoicing:
    def __init__(self, s) -> None:
        self._formatted = s
        self._voicables = "かきくけこさしすせそたちつてとはひふへほカキクケコサシスセソタチツテトハヒフヘホ"

    def _replace(self, s: str, offset: int) -> str:
        c = s[0]
        if c not in self._voicables:
            return s
        if offset == 1:
            if c == "う":
                return "\u3094"
            if c == "ウ":
                return "\u30f4"
        return chr(ord(c) + offset)

    def fix_voicing(self) -> None:
        self._formatted = re.sub(
            r".[\u309b\u3099]",
            lambda mo: self._replace(mo.group(0), 1),
            self._formatted,
        )

    def fix_half_voicing(self) -> None:
        self._formatted = re.sub(
            r".[\u309a\u309c]",
            lambda mo: self._replace(mo.group(0), 2),
            self._formatted,
        )

    @property
    def formatted(self) -> str:
        return self._formatted

def rename_pseudo_voicing() -> None:
    pane = CPane(window)
    items = pane.selectedItems
    for item in items:
        name = item.getName()
        pv = PseudoVoicing(name)
        pv.fix_voicing()
        pv.fix_half_voicing()
        newname = pv.formatted
        if name != newname:
            try:
                item.rename(newname)
                print("RENAMED: '{}' ==> '{}'".format(name, newname))
                item._selected = False
            except Exception as e:
                print(e)

条件指定で選択

非アクティブペインと名前が共通しているものを選んだり、

def select_name_common():
    inactive = CPane(window, False)
    other_names = inactive.names
    pane = CPane(window)
    for i in range(pane.count):
        item = pane.byIndex(i)
        if item.getName() in other_names:
            pane.select(i)
        else:
            pane.unSelect(i)

名前がアクティブペインにしかない名前を選んだり、

def select_name_unique():
    inactive = CPane(window, False)
    other_names = inactive.names
    pane = CPane(window)
    for i in range(pane.count):
        item = pane.byIndex(i)
        if item.getName() in other_names:
            pane.unSelect(i)
        else:
            pane.select(i)

ファイル名に応じて選べるようにしたり(Shift+Enterで確定したら選択解除)。

def select_stem_startswith():
    result, mod = window.commandLine("StartsWith", return_modkey=True)
    if result:
        SELECTOR.stemStartsWith(result, mod == ckit.MODKEY_SHIFT)

def select_stem_endsswith():
    result, mod = window.commandLine("EndsWith", return_modkey=True)
    if result:
        SELECTOR.stemEndsWith(result, mod == ckit.MODKEY_SHIFT)

def select_stem_contains():
    result, mod = window.commandLine("Contains", return_modkey=True)
    if result:
        SELECTOR.stemContains(result, mod == ckit.MODKEY_SHIFT)

拡張子指定で選択する方法はソースを参考にしました。
コマンド入力ボックスが表示されている段階では入力のたびに candidate_handlerが呼び出されているようです。

def select_byext():
    pane = CPane(window)
    exts = pane.extensions

    def _listup_extensions(update_info) -> tuple:
        found = []
        cursor_offset = 0
        for e in exts:
            if e.startswith(update_info.text):
                found.append(e)
        return found, cursor_offset

    result, mod = window.commandLine(
        "Extension",
        auto_complete=True,
        candidate_handler=_listup_extensions,
        return_modkey=True,
    )
    if result:
        if not result.startswith("."):
            result = "." + result
        SELECTOR.byExtension(result, mod == ckit.MODKEY_SHIFT)

ハッシュ値を計算して同一ファイルを探す

アクティブペインのファイルについて、ハッシュ値を非アクティブペイン内の各ファイルと比較して同一ファイルがないか探します。

コードは長いので折りたたみ
def compare_file_hash():
    active_pane = CPane(window, True)
    if len(active_pane.files) < 1:
        print("no files to compare in active pane.")
        return

    inactive_pane = CPane(window, False)
    if len(inactive_pane.files) < 1:
        print("no files to compare in inactive pane.")
        return

    # 全角文字を2とカウントして表示時の「幅」を求める関数
    def bytelen(s: str) -> int:
        n = 0
        for c in s:
            if unicodedata.east_asian_width(c) in "FWA":
                n += 2
            else:
                n += 1
        return n

    buffer_width = 0
    for file in active_pane.files:
        bn = bytelen(file.getName())
        buffer_width = max(buffer_width, bn)
    buffer_width += 2

    print("\n------------------")
    print(" compare md5 hash ")
    print("------------------\n")

    table = {}
    window.setProgressValue(None)

    # ジョブの中断コマンドが呼ばれているか確認する関数
    def _stoppable(job_item: ckit.JobItem) -> bool:
        if job_item.isCanceled():
            return True
        if job_item.waitPaused():
            window.setProgressValue(None)
        return False

    # 非アクティブペインをサブフォルダまで探索してハッシュテーブルを生成する関数
    def _storeInactivePaneHash(job_item: ckit.JobItem) -> None:
        print("scanning...\n")
        for item in inactive_pane.items:
            if _stoppable(job_item):
                break
            if item.isdir():
                for _, _, files in item.walk():
                    if _stoppable(job_item):
                        break
                    for file in files:
                        if _stoppable(job_item):
                            break
                        name = str(
                            Path(file.getFullpath()).relative_to(
                                inactive_pane.currentPath
                            )
                        )
                        digest = hashlib.md5(
                            file.open().read(64 * 1024)
                        ).hexdigest()
                        table[digest] = table.get(digest, []) + [name]
            else:
                name = item.getName()
                digest = hashlib.md5(item.open().read(64 * 1024)).hexdigest()
                table[digest] = table.get(digest, []) + [name]

    # 選択を解除する関数
    def _clearSelection(job_item: ckit.JobItem) -> None:
        window.clearProgress()
        if _stoppable(job_item):
            return
        Selector(window, True).clearAll()
        Selector(window, False).clearAll()

    # アクティブペインの各ファイルについてハッシュ値を比較して、結果を表示する関数
    def _compareHash(job_item: ckit.JobItem) -> None:
        window.setProgressValue(None)
        for item in active_pane.files:
            if _stoppable(job_item):
                break
            name = item.getName()
            digest = hashlib.md5(item.open().read(64 * 1024)).hexdigest()
            if digest in table:
                active_pane.selectByName(name)
                for i, n in enumerate(table[digest]):
                    w = bytelen(name)
                    if i == 0:
                        filler = "=" * (buffer_width - w)
                        print(name, filler, n)
                    else:
                        filler = " " * w + "=" * (buffer_width - w)
                        print("", filler, n)

    # 仕上げの関数
    def _finish(job_item: ckit.JobItem) -> None:
        window.clearProgress()
        if job_item.isCanceled():
            print("Canceled.\n")
            return
        print("\n------------------")
        print("     FINISHED     ")
        print("------------------\n")

    # ジョブを作成して、
    job_prepare = ckit.JobItem(_storeInactivePaneHash, _clearSelection)
    job_compare = ckit.JobItem(_compareHash, _finish)

    # タスクキューに登録。
    window.taskEnqueue(job_prepare, create_new_queue=False)
    window.taskEnqueue(job_compare, create_new_queue=False)

長くなったので再掲。

https://github.com/AWtnb/cfiler

Discussion