Instantly share code, notes, and snippets.
Created
June 21, 2026 02:52
-
Star
0
(0)
You must be signed in to star a gist -
Fork
0
(0)
You must be signed in to fork a gist
-
-
Save doshiraki/3a87e4edc64d7fa5f40d3c0366270183 to your computer and use it in GitHub Desktop.
fluxbox-menu-generate(python)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import os | |
| import re | |
| import hashlib | |
| from enum import Enum | |
| from typing import List, Dict, Optional, Tuple | |
| # GTK3バインディングのインポート | |
| import gi | |
| gi.require_version('Gtk', '3.0') | |
| from gi.repository import Gtk, GdkPixbuf, GLib | |
| # ========================================== | |
| # 1. Enum定義 (意味の明確化) | |
| # ========================================== | |
| class MenuCategory(Enum): | |
| UTILITY = ("Utility", "Accessories") | |
| DEVELOPMENT = ("Development", "Development") | |
| EDUCATION = ("Education", "Education") | |
| GAME = ("Game", "Games") | |
| GRAPHICS = ("Graphics", "Graphics") | |
| MULTIMEDIA = ("AudioVideo", "Multimedia") | |
| NETWORK = ("Network", "Network") | |
| OFFICE = ("Office", "Office") | |
| SETTINGS = ("Settings", "Settings") | |
| SYSTEM = ("System", "System") | |
| OTHER = ("Other", "Other") | |
| def __init__(self, sKey: str, sLabel: str): | |
| self.sKey = sKey | |
| self.sLabel = sLabel | |
| # ========================================== | |
| # 2. コンフィグ管理クラス | |
| # ========================================== | |
| class MenuConfig: | |
| def __init__(self): | |
| self.srcDesktopDirs: List[str] = [ | |
| "/usr/share/applications", | |
| "/usr/local/share/applications", | |
| os.path.expanduser("~/.local/share/applications"), | |
| "/var/lib/snapd/desktop/applications" | |
| ] | |
| self.cntIconSize: int = 32 | |
| self.optForceIconSize: bool = False | |
| self.sMenuTitle: str = "Fluxbox" | |
| self.sTerminal: str = "xterm" | |
| self.sEditor: str = "geany" | |
| self.sFallbackIcon: str = "gtk-missing-image" | |
| # rule: フィルタリング・置換ルール | |
| self.ruleSkipEntries: List[Tuple[str, str]] = [ | |
| ("NoDisplay", r"true|1"), # 非表示設定のアプリだけスキップ | |
| ] | |
| self.ruleSubstitutions: List[Tuple[str, str, str]] = [ | |
| ("Exec", r"gnome-control-center", "env XDG_CURRENT_DESKTOP=GNOME gnome-control-center"), | |
| ] | |
| # ========================================== | |
| # 3. GTKネイティブ アイコン変換エンジン | |
| # ========================================== | |
| class GtkIconEngine: | |
| def __init__(self, cfg: MenuConfig): | |
| self.cntIconSize = cfg.cntIconSize | |
| self.sFallbackIcon = cfg.sFallbackIcon | |
| self.optForceIconSize = cfg.optForceIconSize | |
| self.theme = Gtk.IconTheme.get_default() | |
| self.dstCacheDir = os.path.expanduser("~/.cache/fbmenugen/icons") | |
| os.makedirs(self.dstCacheDir, exist_ok=True) | |
| self._cacheMap: Dict[str, str] = {} | |
| def resolve(self, sIconName: str) -> str: | |
| if not sIconName: return self._resolveFallback() | |
| if sIconName in self._cacheMap: return self._cacheMap[sIconName] | |
| sResolvedPath = "" | |
| pixbuf = None | |
| try: | |
| if os.path.isabs(sIconName) and (os.path.exists(sIconName) or os.path.exists(sIconName + ".png")): | |
| sTarget = sIconName if os.path.exists(sIconName) else sIconName + ".png" | |
| pixbufRaw = GdkPixbuf.Pixbuf.new_from_file(sTarget) | |
| pixbuf = pixbufRaw.scale_simple(self.cntIconSize, self.cntIconSize, GdkPixbuf.InterpType.HYPER) | |
| else: | |
| icon_info = self.theme.lookup_icon(sIconName, self.cntIconSize, 0) | |
| if icon_info: | |
| pixbufRaw = icon_info.load_icon() | |
| if self.optForceIconSize or pixbufRaw.get_width() != self.cntIconSize or pixbufRaw.get_height() != self.cntIconSize: | |
| pixbuf = pixbufRaw.scale_simple(self.cntIconSize, self.cntIconSize, GdkPixbuf.InterpType.HYPER) | |
| else: | |
| pixbuf = pixbufRaw | |
| except GLib.Error: | |
| pass | |
| if pixbuf: | |
| usPixels = pixbuf.get_pixels() | |
| sHash = hashlib.md5(usPixels).hexdigest() | |
| sCachePath = os.path.join(self.dstCacheDir, f"{sHash}.png") | |
| if not os.path.exists(sCachePath): | |
| pixbuf.savev(sCachePath, "png", [], []) | |
| sResolvedPath = sCachePath | |
| else: | |
| sResolvedPath = self._resolveFallback(sOriginalName=sIconName) | |
| self._cacheMap[sIconName] = sResolvedPath | |
| return sResolvedPath | |
| def _resolveFallback(self, sOriginalName: str = "") -> str: | |
| if sOriginalName == self.sFallbackIcon: return "" | |
| if self.sFallbackIcon not in self._cacheMap: | |
| self._cacheMap[self.sFallbackIcon] = self.resolve(self.sFallbackIcon) | |
| return self._cacheMap.get(self.sFallbackIcon, "") | |
| # ========================================== | |
| # 4. アプリケーション解析ロジック | |
| # ========================================== | |
| class DesktopApp: | |
| def __init__(self, sName: str, sExec: str, sCategories: str, sIconPath: str): | |
| self.sName = sName | |
| self.sExec = sExec.split(" %")[0] | |
| self.category = self._classify(sCategories) | |
| self.sIconPath = sIconPath | |
| def _classify(self, sCategories: str) -> MenuCategory: | |
| for cat in MenuCategory: | |
| if cat.sKey in sCategories: return cat | |
| return MenuCategory.OTHER | |
| class DesktopAppParser: | |
| def __init__(self, cfg: MenuConfig): | |
| self.cfg = cfg | |
| self.iconEngine = GtkIconEngine(cfg) | |
| def fetchAll(self) -> Dict[MenuCategory, List[DesktopApp]]: | |
| categoryMap: Dict[MenuCategory, List[DesktopApp]] = {cat: [] for cat in MenuCategory} | |
| for srcDir in self.cfg.srcDesktopDirs: | |
| if not os.path.exists(srcDir): continue | |
| for sFilename in os.listdir(srcDir): | |
| if sFilename.endswith(".desktop"): | |
| app = self._parse(os.path.join(srcDir, sFilename)) | |
| if app: | |
| categoryMap[app.category].append(app) | |
| return categoryMap | |
| def _parse(self, srcFilePath: str) -> Optional[DesktopApp]: | |
| sDataMap: Dict[str, str] = {} | |
| # b: 状態を表す論理値 (Boolean) | |
| bInDesktopEntry = False | |
| try: | |
| with open(srcFilePath, 'r', encoding='utf-8') as srcFile: | |
| for usLine in srcFile: | |
| sLine = usLine.strip() | |
| # 空行やコメントはスキップ | |
| if not sLine or sLine.startswith("#"): | |
| continue | |
| # セクションの切り替わりを検知するステートマシン | |
| if sLine.startswith("["): | |
| if sLine == "[Desktop Entry]": | |
| bInDesktopEntry = True | |
| else: | |
| # アクションセクション等に入ったら読み込みを停止 | |
| bInDesktopEntry = False | |
| continue | |
| # [Desktop Entry] 内のデータのみを抽出 | |
| if bInDesktopEntry and "=" in sLine: | |
| parts = sLine.split("=", 1) | |
| sKey = parts[0].strip() | |
| sValue = parts[1].strip() | |
| # 同一キーの重複(Name[ja] 等の派生)はひとまず最初のベースキーを優先 | |
| if sKey not in sDataMap: | |
| sDataMap[sKey] = sValue | |
| except Exception: | |
| return None | |
| # 必須キーの確認 | |
| if "Name" not in sDataMap or "Exec" not in sDataMap: | |
| return None | |
| # 1. Skip Entries (除外ルールの適用) | |
| for sRuleKey, sRuleRegex in self.cfg.ruleSkipEntries: | |
| if sRuleKey in sDataMap: | |
| if re.search(sRuleRegex, sDataMap[sRuleKey], re.IGNORECASE): | |
| return None # ルールに合致したため破棄 | |
| # 2. Substitutions (置換ルールの適用) | |
| for sRuleKey, sSearchRegex, sReplacement in self.cfg.ruleSubstitutions: | |
| if sRuleKey in sDataMap: | |
| sDataMap[sRuleKey] = re.sub(sSearchRegex, sReplacement, sDataMap[sRuleKey]) | |
| # データの構築 | |
| sName = sDataMap["Name"] | |
| sExec = sDataMap["Exec"] | |
| sCategories = sDataMap.get("Categories", "") | |
| sIconName = sDataMap.get("Icon", "") | |
| sIconCachePath = self.iconEngine.resolve(sIconName) | |
| return DesktopApp(sName, sExec, sCategories, sIconCachePath) | |
| # ========================================== | |
| # 5. メニュー生成 / 基底クラス群 | |
| # ========================================== | |
| class MenuItem: | |
| def render(self) -> str: raise NotImplementedError() | |
| class ExecItem(MenuItem): | |
| def __init__(self, sLabel: str, sExec: str, sIconPath: str = ""): | |
| self.sLabel = sLabel | |
| self.sExec = sExec | |
| self.sIconPath = sIconPath | |
| def render(self) -> str: | |
| sIconStr = f" <{self.sIconPath}>" if self.sIconPath else "" | |
| return f" [exec] ({self.sLabel}) {{{self.sExec}}}{sIconStr}\n" | |
| class SeparatorItem(MenuItem): | |
| def render(self) -> str: return " [separator]\n" | |
| class FluxboxDefaultItem(MenuItem): | |
| def render(self) -> str: | |
| return """ [submenu] (Fluxbox) | |
| [config] (Configure) | |
| [submenu] (System Styles) {Choose a style...} | |
| [stylesdir] (/usr/share/fluxbox/styles) | |
| [end] | |
| [submenu] (User Styles) {Choose a style...} | |
| [stylesdir] (~/.fluxbox/styles) | |
| [end] | |
| [workspaces] (Workspace List) | |
| [reconfig] (Reload config) | |
| [restart] (Restart) | |
| [end]\n""" | |
| class ExitItem(MenuItem): | |
| def render(self) -> str: return " [exit] (Exit)\n" | |
| class FluxboxMenuGenerator: | |
| def __init__(self, cfg: MenuConfig): | |
| self.cfg = cfg | |
| self.dstMenuPath: str = os.path.expanduser("~/.fluxbox/menu") | |
| self.parser = DesktopAppParser(cfg) | |
| def generate(self) -> None: | |
| print(f"🚀 {self.cfg.sMenuTitle} メニューの生成を開始するよ...") | |
| categoryMap = self.parser.fetchAll() | |
| schema: List[MenuItem] = [ | |
| ExecItem("Terminal", self.cfg.sTerminal, self.parser.iconEngine.resolve("utilities-terminal")), | |
| ExecItem("Web Browser", "xdg-open http://", self.parser.iconEngine.resolve("web-browser")), | |
| ExecItem("Run command", "fbrun", self.parser.iconEngine.resolve("system-run")), | |
| SeparatorItem() | |
| ] | |
| os.makedirs(os.path.dirname(self.dstMenuPath), exist_ok=True) | |
| with open(self.dstMenuPath, 'w', encoding='utf-8') as dstFile: | |
| dstFile.write(f"[begin] ({self.cfg.sMenuTitle})\n") | |
| dstFile.write("[encoding] {UTF-8}\n") | |
| for item in schema: dstFile.write(item.render()) | |
| for category in MenuCategory: | |
| apps = categoryMap[category] | |
| if apps: | |
| dstFile.write(f" [submenu] ({category.sLabel})\n") | |
| for app in sorted(apps, key=lambda a: a.sName.lower()): | |
| dstFile.write(ExecItem(app.sName, app.sExec, app.sIconPath).render()) | |
| dstFile.write(" [end]\n") | |
| dstFile.write(SeparatorItem().render()) | |
| dstFile.write(FluxboxDefaultItem().render()) | |
| dstFile.write(ExitItem().render()) | |
| dstFile.write("[end]\n") | |
| print(f"🎉 完了だよ! 構造解析バグを修正したメニューを {self.dstMenuPath} に書き出したよ。") | |
| if __name__ == "__main__": | |
| config = MenuConfig() | |
| generator = FluxboxMenuGenerator(config) | |
| generator.generate() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment