Skip to content

Instantly share code, notes, and snippets.

@doshiraki
Created June 21, 2026 02:52
Show Gist options
  • Select an option

  • Save doshiraki/3a87e4edc64d7fa5f40d3c0366270183 to your computer and use it in GitHub Desktop.

Select an option

Save doshiraki/3a87e4edc64d7fa5f40d3c0366270183 to your computer and use it in GitHub Desktop.
fluxbox-menu-generate(python)
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