cfiler(内骨格)個人用設定まとめ
最新状況(日々更新中)
結構なカスタマイズになったので備忘録として。
以下、基本的に公式のソースを参考に config.py
を書いていきます。
事前準備
各種インポート
コード内で使用するライブラリを読み込んでおきましょう。
コメントで付記しているように、できるだけ型ヒントを書くために(&エディタの警告を減らすために)ソースを参考に追加で色々読み込んでおきます。
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で作っておきます。
中身の例は以下のようなもので、各行がフォルダ名のテンプレートになります。
- 先頭が
#
から始まるものは下記の基準で連番インデックスを振る- 現在のフォルダ内にインデックスが見当たらなければ0を振る
- 連番でインデックスされたフォルダがあれば、その「次」の番号でインデックスを振る
- 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で比較
とても便利! 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)
長くなったので再掲。
Discussion