From efb3305de629c5379337b1e714e26ff6e5756ca0 Mon Sep 17 00:00:00 2001 From: san Date: Sat, 31 Oct 2026 14:01:19 +0100 Subject: [PATCH] [ryoku] fastfetch: preserve markers on save Note: Fixed: Fastfetch styles keep their renderer and theme roles after saving in Settings. --- ryoku/hub/CHANGELOG.md | 3 + ryoku/hub/backend/fastfetch.go | 72 ++++++++--- ryoku/hub/backend/fastfetch_test.go | 125 +++++++++++++++++++ ryoku/hub/quickshell/pages/FastfetchPage.qml | 36 +++++- ryoku/ui/Field.qml | 2 + 5 files changed, 215 insertions(+), 23 deletions(-) diff --git a/ryoku/hub/CHANGELOG.md b/ryoku/hub/CHANGELOG.md index deda5235a..1d0cd9d5b 100644 --- a/ryoku/hub/CHANGELOG.md +++ b/ryoku/hub/CHANGELOG.md @@ -3,6 +3,9 @@ ## Unreleased ### Fixed +- Fastfetch renderer and recolor markers survive Settings saves. Renderer based + styles keep their readout, and wallpaper recoloring keeps its palette roles. + Rows are locked for styles that provide their own renderer. - **Bluetooth can recover when rfkill hides the adapter.** Connections shows the software-blocked state and keeps its power switch available so the shared helper can bring the controller back (`quickshell/pages/ConnectionsPage.qml`). diff --git a/ryoku/hub/backend/fastfetch.go b/ryoku/hub/backend/fastfetch.go index db8827b70..b962f4a6d 100644 --- a/ryoku/hub/backend/fastfetch.go +++ b/ryoku/hub/backend/fastfetch.go @@ -36,14 +36,15 @@ const ( ) var ( - ffAnsiRe = regexp.MustCompile("\x1b\\[[0-9;]*m") - ffTanRe = regexp.MustCompile("\x1b\\[38;2;" + regexp.QuoteMeta(ffTan) + "m(.*?)\x1b\\[0m") - ffLabelRe = regexp.MustCompile("\x1b\\[1;38;2;" + regexp.QuoteMeta(ffBright) + "m(.*?)\x1b\\[0m") - ffKeysColor = regexp.MustCompile(`(?:38;2;)?([0-9]+;[0-9]+;[0-9]+)`) - ffHexColor = regexp.MustCompile(`^#[0-9A-Fa-f]{6}$`) - ffTrueColorRe = regexp.MustCompile("\x1b\\[((?:1;)?)38;2;([0-9]+;[0-9]+;[0-9]+)m") - ffRecolorLineRe = regexp.MustCompile(`(?m)^[\t ]*//[\t ]*ryoku:recolor[\t ]+([^\r\n]+)`) - ffRecolorPairRe = regexp.MustCompile(`([A-Za-z][A-Za-z0-9_-]*)=([A-Za-z][A-Za-z0-9_-]*)`) + ffAnsiRe = regexp.MustCompile("\x1b\\[[0-9;]*m") + ffTanRe = regexp.MustCompile("\x1b\\[38;2;" + regexp.QuoteMeta(ffTan) + "m(.*?)\x1b\\[0m") + ffLabelRe = regexp.MustCompile("\x1b\\[1;38;2;" + regexp.QuoteMeta(ffBright) + "m(.*?)\x1b\\[0m") + ffKeysColor = regexp.MustCompile(`(?:38;2;)?([0-9]+;[0-9]+;[0-9]+)`) + ffHexColor = regexp.MustCompile(`^#[0-9A-Fa-f]{6}$`) + ffTrueColorRe = regexp.MustCompile("\x1b\\[((?:1;)?)38;2;([0-9]+;[0-9]+;[0-9]+)m") + ffRecolorLineRe = regexp.MustCompile(`(?m)^[\t ]*//[\t ]*ryoku:recolor[\t ]+([^\r\n]+)`) + ffRendererLineRe = regexp.MustCompile(`(?m)^//[\t ]*ryoku:renderer[\t ]+(\S+)[\t ]*$`) + ffRecolorPairRe = regexp.MustCompile(`([A-Za-z][A-Za-z0-9_-]*)=([A-Za-z][A-Za-z0-9_-]*)`) // ffSlotTrueColorRe matches a rice's inline fastfetch colour placeholder // "{#38;2;R;G;B}", whose R;G;B is a fixed brand tone baked into the format // (fastfetch resolves it to literal ANSI). The wallpaper pass rewrites the @@ -51,6 +52,31 @@ var ( ffSlotTrueColorRe = regexp.MustCompile(`\{#((?:1;)?38;2;)([0-9]+;[0-9]+;[0-9]+)\}`) ) +func ffRendererLine(config []byte) string { + if match := ffRendererLineRe.FindSubmatch(config); match != nil { + return string(match[1]) + } + return "" +} + +func ffRecolorLine(config []byte) string { + if match := ffRecolorLineRe.FindSubmatch(config); match != nil { + return strings.TrimSpace(string(match[1])) + } + return "" +} + +func ffWithMarkers(config []byte, renderer, recolor string) []byte { + var head []byte + if renderer != "" { + head = append(head, "// ryoku:renderer "+renderer+"\n"...) + } + if recolor != "" { + head = append(head, "// ryoku:recolor "+recolor+"\n"...) + } + return append(head, config...) +} + func ffBaseOS() string { if base, ok := os.LookupEnv("RYOKU_BASE_OS"); ok { return base @@ -195,12 +221,14 @@ type ffRow struct { } type ffModel struct { - Logo ffLogo `json:"logo"` - Accent string `json:"accent"` - Palette string `json:"palette"` - Rows []ffRow `json:"rows"` - Display json.RawMessage `json:"display,omitempty"` - Schema string `json:"schema,omitempty"` + Logo ffLogo `json:"logo"` + Accent string `json:"accent"` + Palette string `json:"palette"` + Renderer string `json:"renderer,omitempty"` + Recolor string `json:"recolor,omitempty"` + Rows []ffRow `json:"rows"` + Display json.RawMessage `json:"display,omitempty"` + Schema string `json:"schema,omitempty"` } type ffPalette struct { @@ -342,6 +370,16 @@ func runFastfetch(args []string) error { return err } } + if m.Renderer == "" || m.Recolor == "" { + if current, err := os.ReadFile(fastfetchConfigPath()); err == nil { + if m.Renderer == "" { + m.Renderer = ffRendererLine(current) + } + if m.Recolor == "" { + m.Recolor = ffRecolorLine(current) + } + } + } b, err := buildFastfetch(m) if err != nil { return err @@ -434,7 +472,7 @@ func loadFastfetch() (ffModel, error) { if err := json.Unmarshal(stripJSONC(raw), &doc); err != nil { return ffModel{}, fmt.Errorf("parse config.jsonc: %w", err) } - m := ffModel{Schema: doc.Schema, Display: doc.Display, Accent: ffDisplayAccent(doc.Display), Palette: readFastfetchPaletteMode(), Logo: ffNormalizeLogo(doc.Logo)} + m := ffModel{Schema: doc.Schema, Display: doc.Display, Accent: ffDisplayAccent(doc.Display), Palette: readFastfetchPaletteMode(), Logo: ffNormalizeLogo(doc.Logo), Renderer: ffRendererLine(raw), Recolor: ffRecolorLine(raw)} baseOS := ffBaseOS() for _, rm := range doc.Modules { m.Rows = append(m.Rows, ffNormalizeModule(rm, baseOS)) @@ -605,7 +643,7 @@ func buildFastfetch(m ffModel) ([]byte, error) { if err != nil { return nil, err } - return append(b, '\n'), nil + return ffWithMarkers(append(b, '\n'), m.Renderer, m.Recolor), nil } func ffApplyPaletteToRow(raw json.RawMessage, palette ffPalette) (json.RawMessage, error) { @@ -823,7 +861,7 @@ func buildFastfetchEffectiveWithRoles(m ffModel, recolor map[string]string) ([]b if err != nil { return nil, err } - return append(b, '\n'), nil + return ffWithMarkers(append(b, '\n'), m.Renderer, m.Recolor), nil } func fastfetchEffectiveFresh(path string, mode string, japaneseGloss bool) bool { diff --git a/ryoku/hub/backend/fastfetch_test.go b/ryoku/hub/backend/fastfetch_test.go index 429dd1ccc..22cef4ef9 100644 --- a/ryoku/hub/backend/fastfetch_test.go +++ b/ryoku/hub/backend/fastfetch_test.go @@ -685,3 +685,128 @@ func TestShippedRiceTaglinesNameTheRunningDistribution(t *testing.T) { } } } + +func TestFastfetchRendererMarkerSurvivesSave(t *testing.T) { + loadSample(t) + if err := os.WriteFile(fastfetchPalettePath(), []byte(sampleWallpaperPalette), 0o644); err != nil { + t.Fatal(err) + } + const marker = "// ryoku:renderer ryoku-filigree/bin/ryoku-fetch" + path := fastfetchConfigPath() + if err := os.WriteFile(path, []byte(marker+"\n"+sampleFF), 0o644); err != nil { + t.Fatal(err) + } + m, err := loadFastfetch() + if err != nil { + t.Fatal(err) + } + if m.Renderer != "ryoku-filigree/bin/ryoku-fetch" { + t.Fatalf("renderer = %q, want ryoku-filigree/bin/ryoku-fetch", m.Renderer) + } + + built, err := buildFastfetch(m) + if err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(string(built), marker+"\n") { + t.Fatalf("fixed rebuild dropped the renderer marker:\n%s", built) + } + effective, err := buildFastfetchEffective(m) + if err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(string(effective), marker+"\n") { + t.Fatalf("wallpaper rebuild dropped the renderer marker:\n%s", effective) + } + + m.Renderer = "" + payload, err := json.Marshal(m) + if err != nil { + t.Fatal(err) + } + if err := runFastfetch([]string{"save", string(payload)}); err != nil { + t.Fatalf("save: %v", err) + } + saved, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(string(saved), marker+"\n") { + t.Fatalf("save dropped the renderer marker:\n%s", saved) + } + + if err := os.WriteFile(path, []byte(sampleFF), 0o644); err != nil { + t.Fatal(err) + } + m, err = loadFastfetch() + if err != nil { + t.Fatal(err) + } + saved, err = buildFastfetch(m) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(saved), "ryoku:renderer") { + t.Fatalf("plain preset gained a renderer marker:\n%s", saved) + } +} + +func TestFastfetchRecolorLineSurvivesSave(t *testing.T) { + loadSample(t) + if err := os.WriteFile(fastfetchPalettePath(), []byte(sampleWallpaperPalette), 0o644); err != nil { + t.Fatal(err) + } + const line = "// ryoku:recolor keys=primary title=foreground output=onSurface" + path := fastfetchConfigPath() + if err := os.WriteFile(path, []byte(line+"\n"+sampleFF), 0o644); err != nil { + t.Fatal(err) + } + m, err := loadFastfetch() + if err != nil { + t.Fatal(err) + } + if want := "keys=primary title=foreground output=onSurface"; m.Recolor != want { + t.Fatalf("recolor = %q, want %q", m.Recolor, want) + } + built, err := buildFastfetch(m) + if err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(string(built), line+"\n") { + t.Fatalf("rebuild dropped the recolor line:\n%s", built) + } + + m.Recolor = "" + payload, err := json.Marshal(m) + if err != nil { + t.Fatal(err) + } + if err := runFastfetch([]string{"save", string(payload)}); err != nil { + t.Fatalf("save: %v", err) + } + saved, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(string(saved), line+"\n") { + t.Fatalf("save dropped the recolor line:\n%s", saved) + } + if roles := ffRecolorRoles(saved); roles["keys"] != "primary" || roles["output"] != "onSurface" { + t.Fatalf("roles no longer parse from the saved config: %v", roles) + } + + if err := os.WriteFile(path, []byte(sampleFF), 0o644); err != nil { + t.Fatal(err) + } + plain, err := loadFastfetch() + if err != nil { + t.Fatal(err) + } + rebuilt, err := buildFastfetch(plain) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(rebuilt), "ryoku:recolor") { + t.Fatalf("plain config gained a recolor line:\n%s", rebuilt) + } +} diff --git a/ryoku/hub/quickshell/pages/FastfetchPage.qml b/ryoku/hub/quickshell/pages/FastfetchPage.qml index 613345cb1..ea6aa62bf 100644 --- a/ryoku/hub/quickshell/pages/FastfetchPage.qml +++ b/ryoku/hub/quickshell/pages/FastfetchPage.qml @@ -92,6 +92,7 @@ Item { void pg.rev; return pg.ready && JSON.stringify(pg.model) !== JSON.stringify(pg.committed); } + readonly property bool compositionLocked: !!(pg.model && pg.model.renderer) // fastfetch's own config path; the head's EDIT CONFIG opens it raw, the same // file the Hub writes on Save so hand-edits and the GUI share one source. @@ -220,6 +221,8 @@ Item { function setAccent(v) { var m = pg.clone(); m.accent = v; pg.commitModel(m); } function setPalette(v) { var m = pg.clone(); m.palette = v; pg.commitModel(m); } function setRow(i, k, v) { + if (pg.compositionLocked) + return; var m = pg.clone(); m.rows[i][k] = v; if (k === "text" && m.rows[i].kind === "tagline") @@ -227,6 +230,8 @@ Item { pg.commitModel(m); } function moveRow(i, d) { + if (pg.compositionLocked) + return; var j = i + d; if (j < 0 || j >= pg.model.rows.length) return; @@ -234,8 +239,14 @@ Item { var t = m.rows[i]; m.rows[i] = m.rows[j]; m.rows[j] = t; pg.commitModel(m); } - function removeRow(i) { var m = pg.clone(); m.rows.splice(i, 1); pg.commitModel(m); } + function removeRow(i) { + if (pg.compositionLocked) + return; + var m = pg.clone(); m.rows.splice(i, 1); pg.commitModel(m); + } function addFromMenu(key) { + if (pg.compositionLocked) + return; var m = pg.clone(); if (key === "__header") m.rows.push({ "kind": "header", "enabled": true, "text": "SECTION", "label": "Section header" }); @@ -658,10 +669,12 @@ Item { id: rt property string glyph: "" property color tint: Tokens.inkDim + property bool armed: true signal tapped() width: 26 height: 26 radius: Tokens.radius + opacity: armed ? 1 : 0.3 color: rh.hovered ? Tokens.tint10 : "transparent" border.width: Tokens.border border.color: rh.hovered ? Tokens.lineStrong : Tokens.line @@ -673,8 +686,8 @@ Item { font.family: Tokens.ui font.pixelSize: 12 // glyph, sized to the button, no matching token } - HoverHandler { id: rh; cursorShape: Qt.PointingHandCursor } - TapHandler { onTapped: rt.tapped() } + HoverHandler { id: rh; enabled: rt.armed; cursorShape: Qt.PointingHandCursor } + TapHandler { enabled: rt.armed; onTapped: rt.tapped() } } // ── head: eyebrow, Fraunces title with EDIT CONFIG, subtitle ───────────── @@ -1291,6 +1304,7 @@ Item { id: addBtn anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter glyph: "+" + armed: !pg.compositionLocked onAct: pg.addOpen = true } Rectangle { @@ -1301,6 +1315,14 @@ Item { } } + Text { + width: parent.width + visible: pg.compositionLocked + text: I18n.tr("This style draws its own composition in the terminal, so its rows are fixed here.") + color: Tokens.inkFaint; font.family: Tokens.ui; font.pixelSize: Tokens.fSmall + wrapMode: Text.WordWrap + } + Repeater { model: pg.model.rows delegate: Rectangle { @@ -1320,8 +1342,8 @@ Item { anchors.left: parent.left; anchors.leftMargin: Tokens.s2 anchors.verticalCenter: parent.verticalCenter spacing: Tokens.s1 - RowTool { glyph: "\u2191"; onTapped: pg.moveRow(rowItem.index, -1) } - RowTool { glyph: "\u2193"; onTapped: pg.moveRow(rowItem.index, 1) } + RowTool { glyph: "\u2191"; armed: !pg.compositionLocked; onTapped: pg.moveRow(rowItem.index, -1) } + RowTool { glyph: "\u2193"; armed: !pg.compositionLocked; onTapped: pg.moveRow(rowItem.index, 1) } } Text { @@ -1342,6 +1364,7 @@ Item { anchors.verticalCenter: parent.verticalCenter visible: pg.rowEditable(rowItem.modelData.kind) placeholder: pg.rowPlaceholder(rowItem.modelData.kind) + readOnly: pg.compositionLocked text: rowItem.modelData.kind === "module" ? (rowItem.modelData.key || "") : (rowItem.modelData.text || "") onCommitted: (v) => { var f = rowItem.modelData.kind === "module" ? "key" : "text"; @@ -1358,9 +1381,10 @@ Item { RowTool { glyph: rowItem.modelData.enabled ? "\u25cf" : "\u25cb" tint: rowItem.modelData.enabled ? Tokens.ink : Tokens.inkFaint + armed: !pg.compositionLocked onTapped: pg.setRow(rowItem.index, "enabled", !rowItem.modelData.enabled) } - RowTool { glyph: "\u2715"; onTapped: pg.removeRow(rowItem.index) } + RowTool { glyph: "\u2715"; armed: !pg.compositionLocked; onTapped: pg.removeRow(rowItem.index) } } } } diff --git a/ryoku/ui/Field.qml b/ryoku/ui/Field.qml index 6e7e478f1..c445b5ba8 100644 --- a/ryoku/ui/Field.qml +++ b/ryoku/ui/Field.qml @@ -11,6 +11,7 @@ Rectangle { property string placeholder: "" property bool tabular: false // mono for what a config file would hold property bool toolbar: false // 36 in a toolbar, 30 in a sheet + property bool readOnly: false // shows a value the screen does not own property bool secret: false // masked, for a password property bool clearOnEscape: true readonly property bool focused: input.activeFocus @@ -43,6 +44,7 @@ Rectangle { selectByMouse: true echoMode: field.secret ? TextInput.Password : TextInput.Normal passwordCharacter: "\u2022" + readOnly: field.readOnly clip: true onTextEdited: field.edited(text) onEditingFinished: field.committed(text)