diff --git a/mihomo-control/README.md b/mihomo-control/README.md
index 13fbd34f..d608a5a9 100644
--- a/mihomo-control/README.md
+++ b/mihomo-control/README.md
@@ -11,20 +11,29 @@ secret.
| Field | Value |
| ------- | --------------------------- |
| ID | `mdj2812/mihomo-control` |
-| Entries | Bar widget: `widget`; panel: `panel`; service: `service`; shortcut: `mode` |
+| Entries | Bar widget: `widget`; panels: `panel`, `panel-attached`; service: `service`; shortcut: `mode` |
## Usage
-Add the **Mihomo Control** widget from the Add-widget picker. It shows the
-current proxy mode (rule / global / direct); hover it for the connection
-status, live download and upload rates, connection count and each proxy
-group's current selection. Left-click the widget to toggle the control panel,
-or open it with:
+Add the **Mihomo Control** widget from the Add-widget picker. By default it
+shows the current proxy mode (rule / global / direct). **Widget label** can
+instead show nothing, download speed, upload speed, both rates, or the active
+connection count. It can also show the first visible proxy group's current
+selection or its selected latency, falling back to that group's best known
+member latency. Its size and color presentation are also configurable. Hover it
+for the connection status, live download and upload rates, connection count and
+each proxy group's current selection. Left-click the widget to toggle the
+control panel using the configured **Panel placement**. Both variants can also
+be opened directly:
```sh
noctalia msg panel-toggle mdj2812/mihomo-control:panel
+noctalia msg panel-toggle mdj2812/mihomo-control:panel-attached
```
+The first command opens the centered floating panel; the second opens the
+variant attached to the bar.
+
The panel lets you switch the proxy mode (rule / global / direct), restart the
server, refresh the status, and manage every proxy group. Each group card
lists all of its members with their latency — sourced from mihomo's health
@@ -33,7 +42,10 @@ shows an overall latency (the selected member's, or the best tested one) right
in its subtitle, visible without expanding. Member lists are collapsed by
default; click a group header to expand it. Click a member to select it; the
current selection is marked with a dot. Use **Test all** next to the Proxy
-groups heading to run a latency test on every group at once.
+groups heading to run a latency test on every group at once. Drag a card by
+its ☰ grip over another card to reorder the groups. The nearest insertion gap
+opens to preview the resulting layout before drop. The custom display order
+survives controller polling and plugin restarts.
Add the **Mihomo: Rule** shortcut from Settings → Control Center shortcuts to
quickly toggle between rule and global mode.
@@ -52,6 +64,11 @@ all communication with the external controller and streams the traffic data.
| Allow insecure TLS | bool | off | Skip certificate verification for self-signed TLS controllers. |
| Test URL | string | `https://www.gstatic.com/generate_204` | URL used for latency tests; empty uses each group's configured test URL. |
| Refresh interval | int | `2` | Seconds between status polls (1–60); traffic rates stream in real time. |
+| Panel placement | select | `floating` | Open the control panel centered on screen or attached to the bar widget. |
+| Widget label | select | `mode` | Show nothing, mode, traffic, connections, or first-group proxy/latency. |
+| Icon size | int | `16` | Bar icon size in pixels (10–32). |
+| Icon color mode | select | `status` | Color the Mihomo glyph by connection status or use a custom color. |
+| Icon color | color | `primary` | Color used by the tintable Mihomo glyph. |
## IPC
@@ -64,16 +81,18 @@ noctalia msg plugin mdj2812/mihomo-control:service all cmd '{"op":"mode","mode":
`refresh` re-polls version, config, connections and proxy groups. `cmd` accepts
the same command tables the panel sends (`mode`, `select`, `delay_test`,
-`delay_test_all`, `restart`, `close_connections`, `refresh`).
+`delay_test_all`, `reorder_group`, `restart`, `close_connections`, `refresh`).
## Notes
- The plugin only talks HTTP to the configured external controller. It spawns
- no processes, runs no external commands, and writes no files.
-- The bar widget and card use the Clash cat logo (`icon.png`), the official
- mascot of the Clash / mihomo project. In the bar widget the cat is tinted by
- connection status: green online, amber while connecting, red offline; the
- panel uses the neutral logo next to its own status indicator.
+ no processes and runs no external commands. It writes only the custom
+ proxy-group display order (group names, never the secret) to its Noctalia
+ plugin data directory. Reordering does not modify the Mihomo configuration.
+- The bar widget uses a tintable glyph traced from the Clash cat silhouette. By
+ default it is green online, amber while connecting and red offline; **Icon
+ color mode** can instead apply one custom color. The panel keeps the official
+ neutral logo next to its own status indicator.
- The traffic rate uses mihomo's streaming `GET /traffic` endpoint; status,
connections and proxy groups are polled every refresh interval.
- The secret is stored in your Noctalia config and sent as the standard
@@ -92,4 +111,5 @@ the same command tables the panel sends (`mode`, `select`, `delay_test`,
- `widget.luau` — bar widget (rates + tooltip).
- `panel.luau` — control panel.
- `shortcut.luau` — rule/global mode toggle.
+- `fonts/` — reviewable vector source and generated tintable Mihomo glyph font.
- `translations/` — user-facing strings.
diff --git a/mihomo-control/fonts/.gitignore b/mihomo-control/fonts/.gitignore
new file mode 100644
index 00000000..43ae0e2a
--- /dev/null
+++ b/mihomo-control/fonts/.gitignore
@@ -0,0 +1,2 @@
+__pycache__/
+*.py[cod]
diff --git a/mihomo-control/fonts/README.md b/mihomo-control/fonts/README.md
new file mode 100644
index 00000000..7343294b
--- /dev/null
+++ b/mihomo-control/fonts/README.md
@@ -0,0 +1,14 @@
+# Mihomo Glyph
+
+`mihomo-glyph.ttf` contains one private-use glyph at `U+E001`, traced from the
+bundled Mihomo cat image. It preserves the recognizable silhouette, eyes, mouth,
+ears, and tail while allowing Noctalia to color it like text.
+
+The accompanying SVG is the reviewable vector source. Regenerate both assets
+with:
+
+```sh
+python3 fonts/generate_mihomo_glyph.py
+```
+
+Generation requires OpenCV and fontTools; neither is needed at runtime.
diff --git a/mihomo-control/fonts/generate_mihomo_glyph.py b/mihomo-control/fonts/generate_mihomo_glyph.py
new file mode 100644
index 00000000..ca172e80
--- /dev/null
+++ b/mihomo-control/fonts/generate_mihomo_glyph.py
@@ -0,0 +1,155 @@
+#!/usr/bin/env python3
+"""Build a tintable icon font from the bundled Mihomo logo.
+
+Development dependencies: OpenCV and fontTools. The generated TTF has one
+private-use glyph at U+E001. It is loaded by the widget at runtime; users do
+not need either dependency.
+"""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+import cv2
+from fontTools.fontBuilder import FontBuilder
+from fontTools.pens.ttGlyphPen import TTGlyphPen
+
+
+ROOT = Path(__file__).resolve().parent
+SOURCE = ROOT.parent / "icon.png"
+SVG_OUTPUT = ROOT / "mihomo-glyph.svg"
+FONT_OUTPUT = ROOT / "mihomo-glyph.ttf"
+UNITS_PER_EM = 1000
+GLYPH_CODEPOINT = 0xE001
+
+
+def signed_area(points: list[tuple[int, int]]) -> float:
+ return sum(
+ x1 * y2 - x2 * y1
+ for (x1, y1), (x2, y2) in zip(points, points[1:] + points[:1])
+ ) / 2
+
+
+def load_contours() -> list[tuple[list[tuple[int, int]], bool]]:
+ image = cv2.imread(str(SOURCE), cv2.IMREAD_UNCHANGED)
+ if image is None or image.shape[2] != 4:
+ raise SystemExit(f"Could not read RGBA source image: {SOURCE}")
+
+ blue, green, red, alpha = cv2.split(image)
+ white = (red > 220) & (green > 220) & (blue > 220)
+ mask = ((alpha > 16) & ~white).astype("uint8") * 255
+ contours, hierarchy = cv2.findContours(mask, cv2.RETR_CCOMP, cv2.CHAIN_APPROX_NONE)
+ if hierarchy is None:
+ raise SystemExit("No contours found in Mihomo source image")
+
+ x, y, width, height = cv2.boundingRect(mask)
+ scale = min(840 / width, 840 / height)
+ x_offset = (UNITS_PER_EM - width * scale) / 2
+ y_offset = 80
+
+ result: list[tuple[list[tuple[int, int]], bool]] = []
+ for index, contour in enumerate(contours):
+ if abs(cv2.contourArea(contour)) < 2:
+ continue
+ simplified = cv2.approxPolyDP(contour, 0.75, True)
+ points = [
+ (
+ round((int(point[0][0]) - x) * scale + x_offset),
+ round((y + height - int(point[0][1])) * scale + y_offset),
+ )
+ for point in simplified
+ ]
+ if len(points) < 3:
+ continue
+
+ is_hole = hierarchy[0][index][3] != -1
+ area = signed_area(points)
+ should_be_clockwise = not is_hole
+ if (should_be_clockwise and area > 0) or (is_hole and area < 0):
+ points.reverse()
+ result.append((points, is_hole))
+
+ if not result:
+ raise SystemExit("No usable contours found in Mihomo source image")
+ return result
+
+
+def build_glyph(contours: list[tuple[list[tuple[int, int]], bool]]):
+ pen = TTGlyphPen(None)
+ for points, _is_hole in contours:
+ pen.moveTo(points[0])
+ for point in points[1:]:
+ pen.lineTo(point)
+ pen.closePath()
+ return pen.glyph()
+
+
+def write_svg(contours: list[tuple[list[tuple[int, int]], bool]]) -> None:
+ paths = []
+ for points, _is_hole in contours:
+ commands = [f"M {points[0][0]} {UNITS_PER_EM - points[0][1]}"]
+ commands.extend(f"L {x} {UNITS_PER_EM - y}" for x, y in points[1:])
+ commands.append("Z")
+ paths.append(" ".join(commands))
+ SVG_OUTPUT.write_text(
+ "\n".join(
+ [
+ '",
+ "",
+ ]
+ ),
+ encoding="utf-8",
+ )
+
+
+def write_font(contours: list[tuple[list[tuple[int, int]], bool]]) -> None:
+ empty_pen = TTGlyphPen(None)
+ glyphs = {
+ ".notdef": empty_pen.glyph(),
+ "space": TTGlyphPen(None).glyph(),
+ "mihomo": build_glyph(contours),
+ }
+
+ builder = FontBuilder(UNITS_PER_EM, isTTF=True)
+ builder.setupGlyphOrder([".notdef", "space", "mihomo"])
+ builder.setupCharacterMap({32: "space", GLYPH_CODEPOINT: "mihomo"})
+ builder.setupGlyf(glyphs)
+ builder.setupHorizontalMetrics({
+ ".notdef": (UNITS_PER_EM, 0),
+ "space": (500, 0),
+ "mihomo": (UNITS_PER_EM, 0),
+ })
+ builder.setupHorizontalHeader(ascent=1000, descent=-200)
+ builder.setupNameTable({
+ "familyName": "Mihomo Glyph",
+ "styleName": "Regular",
+ "uniqueFontIdentifier": "Mihomo Glyph Regular 1.0",
+ "fullName": "Mihomo Glyph Regular",
+ "psName": "MihomoGlyph-Regular",
+ "version": "Version 1.0",
+ })
+ builder.setupOS2(
+ sTypoAscender=1000,
+ sTypoDescender=-200,
+ usWinAscent=1000,
+ usWinDescent=200,
+ )
+ builder.setupPost()
+ builder.setupMaxp()
+ builder.font["head"].created = 2082844800
+ builder.font["head"].modified = 2082844800
+ builder.font.recalcTimestamp = False
+ builder.save(FONT_OUTPUT)
+
+
+def main() -> None:
+ contours = load_contours()
+ write_svg(contours)
+ write_font(contours)
+ print(FONT_OUTPUT)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/mihomo-control/fonts/mihomo-glyph.svg b/mihomo-control/fonts/mihomo-glyph.svg
new file mode 100644
index 00000000..b3cd260d
--- /dev/null
+++ b/mihomo-control/fonts/mihomo-glyph.svg
@@ -0,0 +1,3 @@
+
diff --git a/mihomo-control/fonts/mihomo-glyph.ttf b/mihomo-control/fonts/mihomo-glyph.ttf
new file mode 100644
index 00000000..08eccf35
Binary files /dev/null and b/mihomo-control/fonts/mihomo-glyph.ttf differ
diff --git a/mihomo-control/icon-connecting.png b/mihomo-control/icon-connecting.png
deleted file mode 100644
index b48ff0d1..00000000
Binary files a/mihomo-control/icon-connecting.png and /dev/null differ
diff --git a/mihomo-control/icon-offline.png b/mihomo-control/icon-offline.png
deleted file mode 100644
index 9117b61e..00000000
Binary files a/mihomo-control/icon-offline.png and /dev/null differ
diff --git a/mihomo-control/icon-online.png b/mihomo-control/icon-online.png
deleted file mode 100644
index a4cfddb7..00000000
Binary files a/mihomo-control/icon-online.png and /dev/null differ
diff --git a/mihomo-control/panel.luau b/mihomo-control/panel.luau
index 955edfa5..08abd668 100644
--- a/mihomo-control/panel.luau
+++ b/mihomo-control/panel.luau
@@ -14,6 +14,8 @@
-- and a latency-test button.
local MODES = { "rule", "global", "direct" }
+local GROUP_DRAG_TYPE = "mihomo-proxy-group"
+local GROUP_DROP_HIT_SLOP = 128
local snapshot = {
connection = {},
@@ -292,6 +294,23 @@ local function traffic_card()
})
end
+-- Thin insertion target before every group card, plus one after the final
+-- card. Its drag-only halo covers the neighboring card body; overlapping
+-- targets resolve to the closest insertion point. expandOnDrag then opens a
+-- full-card gap, previewing the resulting layout before the pointer is released.
+local function group_insertion_zone(index)
+ return ui.dropZone({
+ key = "group-gap-" .. index,
+ accepts = { GROUP_DRAG_TYPE },
+ value = tostring(index),
+ onDrop = "onGroupDropped",
+ height = 3,
+ radius = 6,
+ expandOnDrag = true,
+ hitSlop = GROUP_DROP_HIT_SLOP,
+ })
+end
+
local function group_card(group)
local name_text = group.name
if group.hidden == true then
@@ -317,6 +336,22 @@ local function group_card(group)
ui.row({ gap = 6, align = "center", justify = "space_between", onClick = function()
toggle_group(group.name)
end }, {
+ -- Lift the complete slot, including its insertion zone, so no selector
+ -- line remains at the source position while the card is being dragged.
+ ui.dragSource({
+ key = "group-grip-" .. group.name,
+ dragType = GROUP_DRAG_TYPE,
+ payload = group.name,
+ previewAncestor = 3,
+ liftFromLayout = true,
+ width = 20,
+ height = 20,
+ align = "center",
+ justify = "center",
+ tooltip = noctalia.tr("panel.drag_group"),
+ }, {
+ ui.glyph({ name = "menu-2", size = 14, color = "on_surface_variant" }),
+ }),
ui.row({ gap = 4, align = "center", flexGrow = 1 }, name_children),
ui.button({
glyph = "activity",
@@ -381,6 +416,7 @@ local function group_card(group)
end
return ui.column({
+ key = "group-card-" .. group.name,
gap = 8,
border = "outline",
borderWidth = 1,
@@ -390,6 +426,17 @@ local function group_card(group)
}, children)
end
+local function group_slot(group, index)
+ return ui.column({
+ key = "group-slot-" .. group.name,
+ gap = 3,
+ flexGrow = 1,
+ }, {
+ group_insertion_zone(index),
+ group_card(group),
+ })
+end
+
render = function()
local groups = type(snapshot.groups) == "table" and snapshot.groups or {}
@@ -417,14 +464,15 @@ render = function()
}),
}
for i = 1, #groups, 2 do
- local row_children = { group_card(groups[i]) }
+ local row_children = { group_slot(groups[i], i) }
if groups[i + 1] then
- table.insert(row_children, group_card(groups[i + 1]))
+ table.insert(row_children, group_slot(groups[i + 1], i + 1))
else
table.insert(row_children, ui.box({ flexGrow = 1 }))
end
table.insert(group_children, ui.row({ gap = 10, align = "start" }, row_children))
end
+ table.insert(group_children, group_insertion_zone(#groups + 1))
table.insert(body, ui.column({ gap = 10, fill = "surface/0.5", radius = 12, padding = 12 }, group_children))
else
table.insert(body, ui.column({ gap = 10, fill = "surface/0.5", radius = 12, padding = 12 }, {
@@ -460,6 +508,14 @@ render = function()
}))
end
+function onGroupDropped(payload, value)
+ local insertion_index = tonumber(value)
+ if type(payload) ~= "string" or payload == "" or insertion_index == nil then
+ return
+ end
+ send_command({ op = "reorder_group", group = payload, index = insertion_index })
+end
+
for _, key in {
"mihomo.connection",
"mihomo.config",
diff --git a/mihomo-control/plugin.toml b/mihomo-control/plugin.toml
index 8d135645..6abdde11 100644
--- a/mihomo-control/plugin.toml
+++ b/mihomo-control/plugin.toml
@@ -8,7 +8,7 @@
id = "mdj2812/mihomo-control"
name = "Mihomo Control"
-version = "0.1.0"
+version = "0.1.1"
plugin_api = 9
author = "mdj2812"
license = "MIT"
@@ -72,11 +72,69 @@ advanced = true
label_key = "settings.refresh_interval.label"
description_key = "settings.refresh_interval.description"
+[[setting]]
+key = "panel_placement"
+type = "select"
+default = "floating"
+label_key = "settings.panel_placement.label"
+description_key = "settings.panel_placement.description"
+options = [
+ { value = "floating", label_key = "settings.panel_placement.options.floating" },
+ { value = "attached", label_key = "settings.panel_placement.options.attached" },
+]
+
# ── Entries ──────────────────────────────────────────────────────────────────
[[widget]]
id = "widget"
entry = "widget.luau"
+ [[widget.setting]]
+ key = "label_content"
+ type = "select"
+ default = "mode"
+ label_key = "settings.label_content.label"
+ description_key = "settings.label_content.description"
+ options = [
+ { value = "none", label_key = "settings.label_content.options.none" },
+ { value = "mode", label_key = "settings.label_content.options.mode" },
+ { value = "download", label_key = "settings.label_content.options.download" },
+ { value = "upload", label_key = "settings.label_content.options.upload" },
+ { value = "traffic", label_key = "settings.label_content.options.traffic" },
+ { value = "connections", label_key = "settings.label_content.options.connections" },
+ { value = "proxy", label_key = "settings.label_content.options.proxy" },
+ { value = "latency", label_key = "settings.label_content.options.latency" },
+ ]
+
+ [[widget.setting]]
+ key = "icon_size"
+ type = "int"
+ default = 16
+ min = 10
+ max = 32
+ label_key = "settings.icon_size.label"
+ description_key = "settings.icon_size.description"
+
+ [[widget.setting]]
+ key = "icon_color_mode"
+ type = "select"
+ default = "status"
+ label_key = "settings.icon_color_mode.label"
+ description_key = "settings.icon_color_mode.description"
+ options = [
+ { value = "status", label_key = "settings.icon_color_mode.options.status" },
+ { value = "custom", label_key = "settings.icon_color_mode.options.custom" },
+ ]
+
+ [[widget.setting]]
+ key = "icon_color"
+ type = "color"
+ default = "primary"
+ label_key = "settings.icon_color.label"
+ description_key = "settings.icon_color.description"
+ visible_when = { key = "icon_color_mode", values = ["custom"] }
+
+# Panel placement is host-owned, so the widget selects between two entries
+# backed by the same script according to the panel_placement setting.
[[panel]]
id = "panel"
entry = "panel.luau"
@@ -85,6 +143,15 @@ height = 720
placement = "floating"
position = "center"
+[[panel]]
+id = "panel-attached"
+entry = "panel.luau"
+width = 560
+height = 720
+placement = "attached"
+position = "auto"
+open_near_click = true
+
[[service]]
id = "service"
entry = "service.luau"
diff --git a/mihomo-control/service.luau b/mihomo-control/service.luau
index bd0f24b3..e821e017 100644
--- a/mihomo-control/service.luau
+++ b/mihomo-control/service.luau
@@ -9,6 +9,7 @@
-- { op = "select", group = "", proxy = "" }
-- { op = "mode", mode = "rule" | "global" | "direct" }
-- { op = "delay_test", group = "" }
+-- { op = "reorder_group", group = "", index = }
-- { op = "restart" } POST /restart (core re-execs)
-- { op = "close_connections" }
-- { op = "refresh" }
@@ -95,6 +96,84 @@ local state = {
delay = {},
}
+-- Mihomo's /proxies response is a map and therefore has no stable user-facing
+-- group order. Keep the panel's preferred order in the plugin data directory;
+-- newly discovered groups that are not in this list are appended alphabetically.
+local group_order = {}
+local data_path = nil
+
+local function resolve_data_path()
+ if data_path ~= nil then
+ return data_path
+ end
+ local dir = noctalia.pluginDataDir()
+ if dir == nil then
+ return nil
+ end
+ data_path = dir .. "/state.json"
+ return data_path
+end
+
+local function load_group_order()
+ local path = resolve_data_path()
+ if path == nil then
+ return
+ end
+ local raw = noctalia.readFile(path)
+ if raw == nil then
+ return
+ end
+ local ok, decoded = pcall(noctalia.json.decode, raw)
+ if not ok or type(decoded) ~= "table" or type(decoded.groupOrder) ~= "table" then
+ return
+ end
+
+ local seen = {}
+ for _, name in ipairs(decoded.groupOrder) do
+ if type(name) == "string" and name ~= "" and not seen[name] then
+ seen[name] = true
+ table.insert(group_order, name)
+ end
+ end
+end
+
+local function save_group_order()
+ local path = resolve_data_path()
+ if path == nil then
+ return
+ end
+ local ok, encoded = pcall(noctalia.json.encode, { groupOrder = group_order })
+ if not ok or type(encoded) ~= "string" then
+ noctalia.log("[mihomo-control] failed to encode proxy-group order")
+ return
+ end
+ local written, err = noctalia.writeFile(path, encoded)
+ if not written then
+ noctalia.log("[mihomo-control] failed to save proxy-group order: " .. tostring(err or "unknown error"))
+ end
+end
+
+local function apply_group_order(groups)
+ local rank = {}
+ for index, name in ipairs(group_order) do
+ rank[name] = index
+ end
+ table.sort(groups, function(a, b)
+ local a_rank = rank[a.name]
+ local b_rank = rank[b.name]
+ if a_rank ~= nil and b_rank ~= nil then
+ return a_rank < b_rank
+ end
+ if a_rank ~= nil then
+ return true
+ end
+ if b_rank ~= nil then
+ return false
+ end
+ return a.name < b.name
+ end)
+end
+
local function publish(key)
noctalia.state.set("mihomo." .. key, state[key])
end
@@ -269,9 +348,7 @@ local function poll_proxies()
})
end
end
- table.sort(groups, function(a, b)
- return a.name < b.name
- end)
+ apply_group_order(groups)
state.groups = groups
for _, group in groups do
merge_delays(group.name)
@@ -439,6 +516,38 @@ local function test_all_groups()
next_test()
end
+-- Move one group to an insertion gap in the currently published order. Gaps
+-- are numbered 1..#groups+1; removing an item shifts later gaps down by one.
+local function reorder_group(group_name, insertion_index)
+ local from_index = nil
+ for index, group in ipairs(state.groups) do
+ if group.name == group_name then
+ from_index = index
+ break
+ end
+ end
+
+ local insert_at = tonumber(insertion_index)
+ if from_index == nil or insert_at == nil then
+ return
+ end
+
+ insert_at = math.floor(insert_at)
+ local moved = table.remove(state.groups, from_index)
+ if from_index < insert_at then
+ insert_at -= 1
+ end
+ insert_at = math.max(1, math.min(insert_at, #state.groups + 1))
+ table.insert(state.groups, insert_at, moved)
+
+ group_order = {}
+ for _, group in ipairs(state.groups) do
+ table.insert(group_order, group.name)
+ end
+ save_group_order()
+ publish("groups")
+end
+
local function handle_command(cmd)
local op = cmd.op
if op == "refresh" then
@@ -490,6 +599,13 @@ local function handle_command(cmd)
end
return
end
+ if op == "reorder_group" then
+ local group = tostring(cmd.group or "")
+ if group ~= "" then
+ reorder_group(group, cmd.index)
+ end
+ return
+ end
if op == "delay_test_all" then
test_all_groups()
return
@@ -597,6 +713,7 @@ end
-- ── Init ────────────────────────────────────────────────────────────────────
reload_settings()
+load_group_order()
state.connection.host = settings.host
state.connection.port = settings.port
publish("connection")
diff --git a/mihomo-control/translations/en.json b/mihomo-control/translations/en.json
index 9c670859..b148ebc7 100644
--- a/mihomo-control/translations/en.json
+++ b/mihomo-control/translations/en.json
@@ -27,6 +27,44 @@
"refresh_interval": {
"label": "Refresh interval",
"description": "Seconds between status polls (1–60)."
+ },
+ "panel_placement": {
+ "label": "Panel placement",
+ "description": "Choose whether the control panel opens centered on screen or attached to the bar widget.",
+ "options": {
+ "floating": "Floating",
+ "attached": "Attached"
+ }
+ },
+ "label_content": {
+ "label": "Widget label",
+ "description": "Choose what is shown beside the Mihomo icon.",
+ "options": {
+ "none": "None (icon only)",
+ "mode": "Proxy mode",
+ "download": "Download speed",
+ "upload": "Upload speed",
+ "traffic": "Download + upload",
+ "connections": "Connection count",
+ "proxy": "First group selection",
+ "latency": "First group latency"
+ }
+ },
+ "icon_size": {
+ "label": "Icon size",
+ "description": "Size of the bar icon in pixels (10–32)."
+ },
+ "icon_color_mode": {
+ "label": "Icon color mode",
+ "description": "Color the Mihomo glyph by connection status or use one custom color.",
+ "options": {
+ "status": "Connection status",
+ "custom": "Custom"
+ }
+ },
+ "icon_color": {
+ "label": "Icon color",
+ "description": "Color of the tintable Mihomo glyph."
}
},
"panel": {
@@ -42,6 +80,7 @@
"connections": "connections",
"memory": "RAM",
"proxy_groups": "Proxy groups",
+ "drag_group": "Drag to reorder",
"no_groups": "No proxy groups found.",
"delay_test": "Test latency",
"test_all": "Test all",
@@ -68,7 +107,9 @@
"status_online_plain": "Online",
"status_connecting": "Connecting",
"status_offline_with_error": "Offline — {error}",
- "status_offline_plain": "Offline"
+ "status_offline_plain": "Offline",
+ "connections_short": "{count} conn",
+ "latency_timeout": "timeout"
},
"shortcut": {
"label": "Mihomo: {mode}",
diff --git a/mihomo-control/translations/zh-Hans.json b/mihomo-control/translations/zh-Hans.json
index 838ca6e7..f7adba40 100644
--- a/mihomo-control/translations/zh-Hans.json
+++ b/mihomo-control/translations/zh-Hans.json
@@ -27,6 +27,44 @@
"refresh_interval": {
"label": "刷新间隔",
"description": "状态轮询间隔(1–60 秒)。"
+ },
+ "panel_placement": {
+ "label": "面板位置",
+ "description": "选择控制面板在屏幕中央浮动显示,或附着在栏组件旁。",
+ "options": {
+ "floating": "浮动",
+ "attached": "附着"
+ }
+ },
+ "label_content": {
+ "label": "组件标签",
+ "description": "选择 Mihomo 图标旁显示的内容。",
+ "options": {
+ "none": "无(仅图标)",
+ "mode": "代理模式",
+ "download": "下载速度",
+ "upload": "上传速度",
+ "traffic": "下载 + 上传",
+ "connections": "连接数",
+ "proxy": "首个策略组所选代理",
+ "latency": "首个策略组延迟"
+ }
+ },
+ "icon_size": {
+ "label": "图标大小",
+ "description": "栏图标的像素大小(10–32)。"
+ },
+ "icon_color_mode": {
+ "label": "图标颜色模式",
+ "description": "根据连接状态为 Mihomo 字形着色,或使用单一自定义颜色。",
+ "options": {
+ "status": "连接状态",
+ "custom": "自定义"
+ }
+ },
+ "icon_color": {
+ "label": "图标颜色",
+ "description": "可着色 Mihomo 字形的颜色。"
}
},
"panel": {
@@ -42,6 +80,7 @@
"connections": "条连接",
"memory": "内存",
"proxy_groups": "策略组",
+ "drag_group": "拖动以重新排序",
"no_groups": "未找到策略组。",
"delay_test": "测试延迟",
"test_all": "全部测试",
@@ -68,7 +107,9 @@
"status_online_plain": "在线",
"status_connecting": "连接中",
"status_offline_with_error": "离线 — {error}",
- "status_offline_plain": "离线"
+ "status_offline_plain": "离线",
+ "connections_short": "{count} 个连接",
+ "latency_timeout": "超时"
},
"shortcut": {
"label": "Mihomo:{mode}",
diff --git a/mihomo-control/widget.luau b/mihomo-control/widget.luau
index cd5011c4..137758a9 100644
--- a/mihomo-control/widget.luau
+++ b/mihomo-control/widget.luau
@@ -7,6 +7,20 @@
-- proxy group.
local PLUGIN_ID = "mdj2812/mihomo-control"
+local MIHOMO_GLYPH = utf8.char(0xE001)
+local mihomoGlyphFont = noctalia.loadFont("fonts/mihomo-glyph.ttf")
+local STATUS_ICON_COLORS = {
+ online = "#27924f",
+ connecting = "#a35818",
+ offline = "#a72f2f",
+}
+
+local function panel_id()
+ if noctalia.getConfig("panel_placement") == "attached" then
+ return PLUGIN_ID .. ":panel-attached"
+ end
+ return PLUGIN_ID .. ":panel"
+end
local function format_rate(bytes_per_second)
local value = tonumber(bytes_per_second) or 0
@@ -22,6 +36,84 @@ local function format_rate(bytes_per_second)
return string.format("%d B/s", value)
end
+local function configured_icon_size()
+ local value = math.floor(tonumber(noctalia.getConfig("icon_size")) or 16)
+ return math.max(10, math.min(32, value))
+end
+
+local function group_latency(group)
+ if group.now and group.now ~= "" then
+ for _, member in ipairs(type(group.members) == "table" and group.members or {}) do
+ if member.name == group.now and member.delay ~= nil then
+ return tonumber(member.delay)
+ end
+ end
+ end
+
+ local best = nil
+ local any_tested = false
+ for _, member in ipairs(type(group.members) == "table" and group.members or {}) do
+ local delay = tonumber(member.delay)
+ if delay ~= nil then
+ any_tested = true
+ if delay > 0 and (best == nil or delay < best) then
+ best = delay
+ end
+ end
+ end
+ if best ~= nil then
+ return best
+ end
+ return any_tested and 0 or nil
+end
+
+local function first_visible_group(groups)
+ for _, group in ipairs(type(groups) == "table" and groups or {}) do
+ if group.hidden ~= true then
+ return group
+ end
+ end
+ return nil
+end
+
+local function configured_label(mode, traffic, connections, groups)
+ local content = noctalia.getConfig("label_content") or "mode"
+ if content == "none" then
+ return nil
+ end
+ if content == "download" then
+ return `▼ {format_rate(traffic.down)}`
+ end
+ if content == "upload" then
+ return `▲ {format_rate(traffic.up)}`
+ end
+ if content == "traffic" then
+ return `▼ {format_rate(traffic.down)} ▲ {format_rate(traffic.up)}`
+ end
+ if content == "connections" then
+ return noctalia.tr("widget.connections_short", {
+ count = math.max(0, math.floor(tonumber(connections.count) or 0)),
+ })
+ end
+ if content == "proxy" then
+ local group = first_visible_group(groups)
+ local selected = group and tostring(group.now or "") or ""
+ return selected ~= "" and selected or "—"
+ end
+ if content == "latency" then
+ local group = first_visible_group(groups)
+ local delay = group and group_latency(group) or nil
+ if delay == nil then
+ return "— ms"
+ end
+ if delay <= 0 then
+ return noctalia.tr("widget.latency_timeout")
+ end
+ return `{math.floor(delay)} ms`
+ end
+ return mode
+end
+
local function render()
local conn = noctalia.state.get("mihomo.connection") or {}
local config = noctalia.state.get("mihomo.config") or {}
@@ -31,18 +123,39 @@ local function render()
local online = conn.status == "online"
local mode = noctalia.tr("panel.mode_" .. (config.mode or "rule"))
- local icon_path = "icon-offline.png"
- if online then
- icon_path = "icon-online.png"
- elseif conn.status == "connecting" then
- icon_path = "icon-connecting.png"
+
+ local label_text = configured_label(mode, traffic, connections, groups)
+ local icon_size = configured_icon_size()
+ local icon
+ if mihomoGlyphFont ~= nil then
+ local color = STATUS_ICON_COLORS[conn.status] or STATUS_ICON_COLORS.offline
+ if noctalia.getConfig("icon_color_mode") == "custom" then
+ color = noctalia.getConfig("icon_color") or "primary"
+ end
+ icon = ui.label({
+ text = MIHOMO_GLYPH,
+ fontFamily = mihomoGlyphFont,
+ fontSize = icon_size,
+ color = color,
+ baseline = "inkCentered",
+ maxLines = 1,
+ })
+ else
+ icon = ui.image({ path = "icon.png", width = icon_size, height = icon_size })
+ end
+
+ local children = { icon }
+ if label_text ~= nil then
+ table.insert(children, ui.label({
+ text = label_text,
+ fontSize = 12,
+ color = "on_surface",
+ fontWeight = "medium",
+ }))
end
local container = barWidget.isVertical() and ui.column or ui.row
- barWidget.render(container({ gap = 6, align = "center" }, {
- ui.image({ path = icon_path, width = 16, height = 16 }),
- ui.label({ text = mode, fontSize = 12, color = "on_surface", fontWeight = "medium" }),
- }))
+ barWidget.render(container({ gap = label_text ~= nil and 6 or 0, align = "center" }, children))
local status_value = "Offline"
if online then
@@ -93,8 +206,12 @@ function update()
render()
end
+function onConfigChanged()
+ render()
+end
+
function onClick()
- noctalia.togglePanel(PLUGIN_ID .. ":panel")
+ noctalia.togglePanel(panel_id())
end
render()