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( + [ + '', + f' ', + "", + "", + ] + ), + 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()