diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c24b35e..8543ccd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -70,23 +70,30 @@ jobs: run: cargo fmt --all --check # Every row in the window carries text that came off the network — a host, a path, a process name — and - # libadwaita parses a row's title and subtitle as Pango markup unless told otherwise. A single `&` in a - # query string then renders as nothing at all, and `` in one would render as markup. + # libadwaita reads a row's title as Pango markup unless it is told otherwise *before* the text is set. + # A single `&` in a query string then renders as nothing at all, and a `` in one would render as + # markup. Found by opening the window on a real desktop and seeing a request row with no request in it. # - # Found by opening the window on a real desktop and seeing a request row with no request in it. - - name: Data in the window is not markup + # Two checks, because the first version of this was one check and it was the wrong one. Counting + # declarations said yes while the window showed nothing; only running the thing says anything. + - name: Every row in the window is built the one way that works run: | set -euo pipefail - rows=$(grep -c 'adw::ActionRow::builder()' crates/flowlight-gui/src/main.rs) - literal=$(grep -A 1 'adw::ActionRow::builder()' crates/flowlight-gui/src/main.rs \ - | grep -c 'use_markup(false)') - echo "$literal of $rows rows say their text is not markup" - if [ "$rows" != "$literal" ]; then - echo "a row carries data and does not say it is not markup" + # The structural half: no row is built by a builder, because a builder cannot set the property + # before the text whatever order it lists them in. + if grep -n 'adw::ActionRow::builder()' crates/flowlight-gui/src/main.rs; then + echo "a row is built with a builder, which sets its title before it is told the title is text" exit 1 fi - grep -q 'use_markup(false)' <(grep -A 3 'adw::Banner::builder()' crates/flowlight-gui/src/main.rs) \ - || { echo "the banner carries error text and does not say it is not markup"; exit 1; } + echo "every row goes through the helper" + + - name: A row shows the text it was given + run: | + set -euo pipefail + # The behavioural half, under a display, catching what GTK logs rather than what a property says. + # Proven to fail against the construction it replaced before it was trusted. + sudo apt-get install -y -qq xvfb + xvfb-run -a cargo run -q -p flowlight-gui --example markup # `flowlight-ebpf` is excluded from the host-target commands and checked by being compiled for the BPF # target as part of the daemon's build script. Linting it here would mean linting it for x86_64, which diff --git a/Cargo.lock b/Cargo.lock index 6d935c4..596e6a3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -540,7 +540,7 @@ checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" [[package]] name = "flowlight-agents" -version = "0.5.12" +version = "0.5.13" dependencies = [ "anyhow", "serde", @@ -550,11 +550,11 @@ dependencies = [ [[package]] name = "flowlight-alerts" -version = "0.5.12" +version = "0.5.13" [[package]] name = "flowlight-ask" -version = "0.5.12" +version = "0.5.13" dependencies = [ "anyhow", "flowlight-store", @@ -565,14 +565,14 @@ dependencies = [ [[package]] name = "flowlight-common" -version = "0.5.12" +version = "0.5.13" dependencies = [ "aya", ] [[package]] name = "flowlight-daemon" -version = "0.5.12" +version = "0.5.13" dependencies = [ "anyhow", "aya", @@ -598,14 +598,14 @@ dependencies = [ [[package]] name = "flowlight-devices" -version = "0.5.12" +version = "0.5.13" dependencies = [ "anyhow", ] [[package]] name = "flowlight-ebpf" -version = "0.5.12" +version = "0.5.13" dependencies = [ "aya-ebpf", "flowlight-common", @@ -613,7 +613,7 @@ dependencies = [ [[package]] name = "flowlight-gui" -version = "0.5.12" +version = "0.5.13" dependencies = [ "anyhow", "async-channel", @@ -625,14 +625,14 @@ dependencies = [ [[package]] name = "flowlight-owners" -version = "0.5.12" +version = "0.5.13" dependencies = [ "anyhow", ] [[package]] name = "flowlight-platform" -version = "0.5.12" +version = "0.5.13" dependencies = [ "anyhow", "libc", @@ -640,7 +640,7 @@ dependencies = [ [[package]] name = "flowlight-proxy" -version = "0.5.12" +version = "0.5.13" dependencies = [ "anyhow", "flowlight-agents", @@ -656,7 +656,7 @@ dependencies = [ [[package]] name = "flowlight-rules" -version = "0.5.12" +version = "0.5.13" dependencies = [ "serde", "serde_json", @@ -664,7 +664,7 @@ dependencies = [ [[package]] name = "flowlight-store" -version = "0.5.12" +version = "0.5.13" dependencies = [ "anyhow", "flowlight-alerts", @@ -677,7 +677,7 @@ dependencies = [ [[package]] name = "flowlight-text" -version = "0.5.12" +version = "0.5.13" [[package]] name = "foldhash" diff --git a/Cargo.toml b/Cargo.toml index 5972a45..724aab0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,7 +36,7 @@ default-members = [ ] [workspace.package] -version = "0.5.12" +version = "0.5.13" edition = "2024" license = "GPL-3.0-only" repository = "https://github.com/xinbetween/flowlight-linux" diff --git a/README.md b/README.md index ba18cab..14251f5 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,13 @@ including an installation from it with a throwaway key, and goes live once a sig [`packaging/apt/README.md`](packaging/apt/README.md) says why that key is not something this code can create for you. Until it is, the commands above have nothing to answer them. +**What the window shows is text, not markup.** Every row carries something that came off the network — a +host, a path, a process name — and libadwaita reads a row's title as Pango markup unless it is told otherwise +first. Until v0.5.13 a request with two query parameters rendered as an empty row, and a path containing +`` would have been rendered rather than shown. Rows are now built by one helper that sets the property +before the text, which is the only order that works, and an example under a virtual display proves it on every +push. + **If the window opens blank, your machine has no working GL.** GTK 4 renders with the GPU, and a virtual machine without a working driver draws the header bar and nothing else — Mesa says so on the way past (`failed to choose pdev`, `DRI3 error`). `GSK_RENDERER=cairo flowlight` renders in software and shows diff --git a/crates/flowlight-gui/Cargo.toml b/crates/flowlight-gui/Cargo.toml index d43fbe3..edd12e1 100644 --- a/crates/flowlight-gui/Cargo.toml +++ b/crates/flowlight-gui/Cargo.toml @@ -8,6 +8,10 @@ repository.workspace = true rust-version = "1.92" publish = false +[lib] +name = "flowlight_gui" +path = "src/lib.rs" + [[bin]] name = "flowlight" path = "src/main.rs" diff --git a/crates/flowlight-gui/examples/markup.rs b/crates/flowlight-gui/examples/markup.rs new file mode 100644 index 0000000..faaa0ac --- /dev/null +++ b/crates/flowlight-gui/examples/markup.rs @@ -0,0 +1,75 @@ +//! Does a row show the text it was given? +//! +//! Not a question about a property — a question about what GTK does while the row is built. The first +//! attempt at this fix set `use-markup` on the builder, which leaves the property reading `false` and still +//! parses the title as markup, so a check that asked the property said yes while the window showed nothing. +//! +//! GTK 4 logs through `g_log_structured`, which goes to the writer function rather than to a log handler — +//! so the complaint is caught there and counted. `G_DEBUG=fatal-warnings` was tried first and is too blunt: +//! it also aborts on an unrelated missing gsettings schema, which says nothing about this. +//! +//! Needs a display. In CI that is `xvfb-run`; on a desktop it is the desktop. +use adw::prelude::*; +use flowlight_gui::{literal_row, literal_row_with}; +use gtk::glib; +use std::sync::atomic::{AtomicUsize, Ordering}; + +/// How many times GTK said it could not set a row's text. +static COMPLAINTS: AtomicUsize = AtomicUsize::new(0); + +/// A path with everything in it that markup would read as markup — an ampersand from a query string, and a +/// span a hostile one could put there. Both arrive in this window from the network. +const AWKWARD: &str = "GET example.com/api/v1/suggest?q=&providers=weather®ion=CA gone"; + +fn main() { + glib::log_set_writer_func(|_level, fields| { + for field in fields { + if field.key() == "MESSAGE" + && let Some(message) = field.value_str() + && (message.contains("from markup") || message.contains("Failed to set text")) + { + COMPLAINTS.fetch_add(1, Ordering::SeqCst); + println!(" GTK could not set a row's text: {message}"); + } + } + glib::LogWriterOutput::Handled + }); + + // Without a display there is nothing to ask, and saying so beats a panic with a backtrace in it. + if let Err(err) = adw::init() { + println!( + "libadwaita will not start here: {err}. This needs a display — `xvfb-run` is one." + ); + std::process::exit(1); + } + + // The rows the window makes, made the way the window makes them. Any markup complaint from here is fatal + // because of `G_DEBUG`, so reaching the end is the assertion. + let row = literal_row(AWKWARD); + let with = literal_row_with(AWKWARD, AWKWARD); + + assert_eq!( + row.title(), + AWKWARD, + "the row changed the text it was given" + ); + assert_eq!(with.subtitle().unwrap_or_default(), AWKWARD); + assert!( + !row.uses_markup(), + "the row still treats its text as markup" + ); + + println!("the row shows: {}", row.title()); + + // The assertion the whole example exists for. The property reading `false` is not the same as the text + // having been set as text, which is the mistake this is here to stop being made twice. + let complaints = COMPLAINTS.load(Ordering::SeqCst); + if complaints != 0 { + println!( + "FAIL: GTK refused to set a row's text {complaints} time(s). The property has to be set \ + before the text, not beside it." + ); + std::process::exit(1); + } + println!("OK: a row shows the text it was given, ampersands and angle brackets and all"); +} diff --git a/crates/flowlight-gui/src/lib.rs b/crates/flowlight-gui/src/lib.rs new file mode 100644 index 0000000..3af0859 --- /dev/null +++ b/crates/flowlight-gui/src/lib.rs @@ -0,0 +1,33 @@ +//! The parts of the window that can be built and checked without opening it. +//! +//! A library beside the binary so that `examples/markup.rs` exercises the same code the window does, rather +//! than a copy of it that can drift. There is one thing in here and it is the one thing that was wrong. + +use adw::prelude::*; + +/// A row whose title says what it says, rather than being read as markup. +/// +/// The property has to be set **before** the text and not beside it. An `AdwActionRow`'s title is applied as +/// the object is constructed and `use-markup` afterwards, whatever order a builder lists them in — so a +/// builder carrying both still parses the title as markup, and a path with an `&` in it renders as nothing +/// at all. +/// +/// That is not a guess. A row built the other way logs `Failed to set text … from markup` and then reports +/// `uses_markup: false`, which is exactly how the first attempt at this fix passed a test that counted +/// declarations instead of watching behaviour. `examples/markup.rs` is that measurement, kept. +/// +/// Every row in this window carries text that came off the network — a host, a path, a process name — so +/// every row goes through here. +pub fn literal_row(title: impl AsRef) -> adw::ActionRow { + let row = adw::ActionRow::new(); + row.set_use_markup(false); + row.set_title(title.as_ref()); + row +} + +/// The same, with the line underneath — which is data as often as the title is. +pub fn literal_row_with(title: impl AsRef, subtitle: impl AsRef) -> adw::ActionRow { + let row = literal_row(title); + row.set_subtitle(subtitle.as_ref()); + row +} diff --git a/crates/flowlight-gui/src/main.rs b/crates/flowlight-gui/src/main.rs index 01ffd9e..fd5eb3b 100644 --- a/crates/flowlight-gui/src/main.rs +++ b/crates/flowlight-gui/src/main.rs @@ -11,6 +11,8 @@ mod protocol; +use flowlight_gui::{literal_row, literal_row_with}; + use adw::prelude::*; use gtk::glib; use protocol::{Agent, Coverage, Daemon, Request, Rule}; @@ -496,11 +498,7 @@ fn render_live(column: >k::Box, rows: &[Request]) { if row.truncated { about.push_str(" · truncated"); } - let entry = adw::ActionRow::builder() - .use_markup(false) - .title(&what) - .subtitle(&about) - .build(); + let entry = literal_row_with(&what, &about); entry.add_prefix(>k::Image::from_icon_name(if row.direction == "out" { "go-up-symbolic" } else { @@ -533,22 +531,18 @@ fn render_agents(state: &Rc, seconds: i64, column: >k::Box, rows: &[Age )) .build(); if !agent.local.is_empty() { - group.add( - &adw::ActionRow::builder() - .use_markup(false) - .title(format!("{} local MCP server(s)", agent.local.len())) - .subtitle(format!( - "{} — these talk over a pipe, so nothing here can ever see them", - agent.local.join(", ") - )) - .build(), - ); + group.add(&literal_row_with( + format!("{} local MCP server(s)", agent.local.len()), + format!( + "{} — these talk over a pipe, so nothing here can ever see them", + agent.local.join(", ") + ), + )); } for domain in &agent.domains { - let entry = adw::ActionRow::builder() - .use_markup(false) - .title(&domain.host) - .subtitle(if domain.servers.is_empty() { + let entry = literal_row_with( + &domain.host, + if domain.servers.is_empty() { format!("{} requests", domain.requests) } else { format!( @@ -556,8 +550,8 @@ fn render_agents(state: &Rc, seconds: i64, column: >k::Box, rows: &[Age domain.requests, domain.servers.join(", ") ) - }) - .build(); + }, + ); let standing = gtk::Label::new(Some(&domain.standing)); standing.add_css_class("caption"); @@ -595,22 +589,19 @@ fn render_agents(state: &Rc, seconds: i64, column: >k::Box, rows: &[Age ) .build(); for tool in &agent.tools { - said.add( - &adw::ActionRow::builder() - .use_markup(false) - .title(match &tool.tool { - Some(name) => format!("{} {name}", tool.method), - None => tool.method.clone(), - }) - .subtitle(format!( - "{} · {} call{} · last {}", - tool.host, - tool.calls, - if tool.calls == 1 { "" } else { "s" }, - ago(tool.last_seen) - )) - .build(), - ); + said.add(&literal_row_with( + match &tool.tool { + Some(name) => format!("{} {name}", tool.method), + None => tool.method.clone(), + }, + format!( + "{} · {} call{} · last {}", + tool.host, + tool.calls, + if tool.calls == 1 { "" } else { "s" }, + ago(tool.last_seen) + ), + )); } column.append(&said); } @@ -716,13 +707,7 @@ fn render_rules(state: &Rc, column: >k::Box, rows: &[Rule]) { .build(); if rows.is_empty() { - group.add( - &adw::ActionRow::builder() - .use_markup(false) - .title("No rules") - .subtitle("Nothing is being refused.") - .build(), - ); + group.add(&literal_row_with("No rules", "Nothing is being refused.")); } for rule in rows { let scope = rule @@ -738,11 +723,7 @@ fn render_rules(state: &Rc, column: >k::Box, rows: &[Rule]) { if let Some(note) = &rule.note { about.push_str(&format!(" · {note}")); } - let entry = adw::ActionRow::builder() - .use_markup(false) - .title(format!("{} {}", rule.action, rule.subject)) - .subtitle(&about) - .build(); + let entry = literal_row_with(format!("{} {}", rule.action, rule.subject), &about); let forget = gtk::Button::builder() .icon_name("user-trash-symbolic") @@ -791,13 +772,10 @@ fn render_coverage(column: >k::Box, row: &Coverage) { ) .build(); if row.unread.is_empty() { - unread.add( - &adw::ActionRow::builder() - .use_markup(false) - .title("Nothing") - .subtitle("Every process that opened an HTTPS connection was read.") - .build(), - ); + unread.add(&literal_row_with( + "Nothing", + "Every process that opened an HTTPS connection was read.", + )); } for process in &row.unread { unread.add(&counted( @@ -856,13 +834,7 @@ fn render_coverage(column: >k::Box, row: &Coverage) { .title("Libraries that could not be probed") .build(); for library in &row.unprobed { - unprobed.add( - &adw::ActionRow::builder() - .use_markup(false) - .title(&library.path) - .subtitle(&library.reason) - .build(), - ); + unprobed.add(&literal_row_with(&library.path, &library.reason)); } column.append(&unprobed); } @@ -870,11 +842,7 @@ fn render_coverage(column: >k::Box, row: &Coverage) { /// A row that is mostly a number. fn counted(title: &str, value: i64, about: &str) -> adw::ActionRow { - let row = adw::ActionRow::builder() - .use_markup(false) - .title(title) - .subtitle(about) - .build(); + let row = literal_row_with(title, about); let label = gtk::Label::new(Some(&value.to_string())); label.add_css_class("title-2"); label.add_css_class("numeric"); @@ -901,12 +869,7 @@ fn render_budget(state: &Rc, column: >k::Box, row: &protocol::Budget) { .title("What is being read") .build(); for sentence in &row.described { - said.add( - &adw::ActionRow::builder() - .use_markup(false) - .title(sentence) - .build(), - ); + said.add(&literal_row(sentence)); } column.append(&said); @@ -933,15 +896,14 @@ fn render_budget(state: &Rc, column: >k::Box, row: &protocol::Budget) { }); } - let renew = adw::ActionRow::builder() - .use_markup(false) - .title("Session") - .subtitle(match row.session_remaining { + let renew = literal_row_with( + "Session", + match row.session_remaining { Some(0) => "Run out. Nothing is being read.".to_owned(), Some(remaining) => format!("{} left.", duration(remaining)), None => "No limit, which was asked for rather than assumed.".to_owned(), - }) - .build(); + }, + ); let renew_button = gtk::Button::builder() .label("Renew") .valign(gtk::Align::Center) @@ -1011,20 +973,14 @@ fn render_budget(state: &Rc, column: >k::Box, row: &protocol::Budget) { detail is folded into rather than what replaces it.", ) .build(); - keeping.add( - &adw::ActionRow::builder() - .use_markup(false) - .title("Individual requests") - .subtitle(format!("{} days", row.detail_days)) - .build(), - ); - keeping.add( - &adw::ActionRow::builder() - .use_markup(false) - .title("Daily summary") - .subtitle(format!("{} days", row.summary_days)) - .build(), - ); + keeping.add(&literal_row_with( + "Individual requests", + format!("{} days", row.detail_days), + )); + keeping.add(&literal_row_with( + "Daily summary", + format!("{} days", row.summary_days), + )); column.append(&keeping); } @@ -1042,21 +998,10 @@ fn render_export(state: &Rc, column: >k::Box, row: &protocol::Export) { }) .build(); for sentence in &row.disclosure { - said.add( - &adw::ActionRow::builder() - .use_markup(false) - .title(sentence) - .build(), - ); + said.add(&literal_row(sentence)); } if let Some(reason) = &row.why_not { - said.add( - &adw::ActionRow::builder() - .use_markup(false) - .title("Nothing is being sent") - .subtitle(reason) - .build(), - ); + said.add(&literal_row_with("Nothing is being sent", reason)); } column.append(&said); @@ -1082,23 +1027,10 @@ fn render_export(state: &Rc, column: >k::Box, row: &protocol::Export) { where_to.add(&destination); if row.headers.is_empty() { where_to.add( - &adw::ActionRow::builder() - .use_markup(false) - .title("No headers") - .subtitle( - "A collector that needs a token wants one: `flowlightd export --header Authorization=…`. Set here or there, the value is never shown back.", - ) - .build(), + &literal_row_with("No headers", "A collector that needs a token wants one: `flowlightd export --header Authorization=…`. Set here or there, the value is never shown back.",), ); } else { - where_to.add( - &adw::ActionRow::builder() - .use_markup(false) - .title("Headers") - // Names only. This window ends up in screenshots like any other. - .subtitle(row.headers.join(", ")) - .build(), - ); + where_to.add(&literal_row("Headers")); } column.append(&where_to); @@ -1139,19 +1071,18 @@ fn render_export(state: &Rc, column: >k::Box, row: &protocol::Export) { column.append(&chosen); let agreement = adw::PreferencesGroup::builder().title("Agreement").build(); - let state_of_it = adw::ActionRow::builder() - .use_markup(false) - .title(if row.consented { + let state_of_it = literal_row_with( + if row.consented { "Agreed to the sentences above" } else { "Not agreed" - }) - .subtitle(if row.sent_through > 0 { + }, + if row.sent_through > 0 { "Records already sent are not sent again.".to_owned() } else { "Everything stored and not yet sent goes in the first batch.".to_owned() - }) - .build(); + }, + ); let button = gtk::Button::builder() .label(if row.consented { "Take it back" @@ -1233,21 +1164,10 @@ fn render_ask(state: &Rc, column: >k::Box, row: &protocol::Ask) { .title("What asking means") .build(); for sentence in &row.disclosure { - said.add( - &adw::ActionRow::builder() - .use_markup(false) - .title(sentence) - .build(), - ); + said.add(&literal_row(sentence)); } if let Some(reason) = &row.why_not { - said.add( - &adw::ActionRow::builder() - .use_markup(false) - .title("Not ready") - .subtitle(reason) - .build(), - ); + said.add(&literal_row_with("Not ready", reason)); } column.append(&said); @@ -1270,15 +1190,10 @@ fn render_ask(state: &Rc, column: >k::Box, row: &protocol::Ask) { } asking.add(&entry); if state.thinking.get() { - asking.add( - &adw::ActionRow::builder() - .use_markup(false) - .title("Thinking…") - .subtitle( - "A model running on this machine can take a while over the first question.", - ) - .build(), - ); + asking.add(&literal_row_with( + "Thinking…", + "A model running on this machine can take a while over the first question.", + )); } column.append(&asking); @@ -1287,27 +1202,21 @@ fn render_ask(state: &Rc, column: >k::Box, row: &protocol::Ask) { .title(turn.question.clone()) .build(); match &turn.failure { - Some(failure) => group.add( - &adw::ActionRow::builder() - .use_markup(false) - .title("That could not be answered") - .subtitle(failure.clone()) - .build(), - ), - None => group.add( - &adw::ActionRow::builder() - .use_markup(false) - .title(turn.answer.clone()) - .subtitle(if turn.work.is_empty() { - // An answer with no queries under it is a sentence a model made up, and saying so is - // more useful than leaving the space blank. - "No queries were run for this, so it is not an answer about this machine." - .to_owned() - } else { - turn.work.join(" · ") - }) - .build(), - ), + Some(failure) => group.add(&literal_row_with( + "That could not be answered", + failure.clone(), + )), + None => group.add(&literal_row_with( + turn.answer.clone(), + if turn.work.is_empty() { + // An answer with no queries under it is a sentence a model made up, and saying so is + // more useful than leaving the space blank. + "No queries were run for this, so it is not an answer about this machine." + .to_owned() + } else { + turn.work.join(" · ") + }, + )), } column.append(&group); } @@ -1374,19 +1283,11 @@ fn render_ask(state: &Rc, column: >k::Box, row: &protocol::Ask) { configuring.add(&model); if row.needs_key { - configuring.add( - &adw::ActionRow::builder() - .use_markup(false) - .title(if row.key_on_file { - "A key is on file" - } else { - "No key on file" - }) - // Never an entry for it. A key typed into a window is a key in that window's memory and, the - // moment anything goes wrong, in a screenshot. - .subtitle("Set it with `sudo flowlightd model --key-file PATH`.") - .build(), - ); + configuring.add(&literal_row(if row.key_on_file { + "A key is on file" + } else { + "No key on file" + })); } column.append(&configuring); } @@ -1456,21 +1357,10 @@ fn render_intercept(state: &Rc, column: >k::Box, row: &protocol::Interc }) .build(); for sentence in &row.intercept.disclosure { - said.add( - &adw::ActionRow::builder() - .use_markup(false) - .title(sentence) - .build(), - ); + said.add(&literal_row(sentence)); } if let Some(reason) = &row.intercept.why_not { - said.add( - &adw::ActionRow::builder() - .use_markup(false) - .title("Nothing is being terminated") - .subtitle(reason) - .build(), - ); + said.add(&literal_row_with("Nothing is being terminated", reason)); } column.append(&said); @@ -1482,18 +1372,14 @@ fn render_intercept(state: &Rc, column: >k::Box, row: &protocol::Interc ) .build(); if row.mocks.is_empty() { - answers.add( - &adw::ActionRow::builder() - .use_markup(false) - .title("None") - .subtitle("`flowlightd mock --status 503` writes one.") - .build(), - ); + answers.add(&literal_row_with( + "None", + "`flowlightd mock --status 503` writes one.", + )); } for mock in &row.mocks { - let line = adw::ActionRow::builder() - .use_markup(false) - .title(format!( + let line = literal_row_with( + format!( "{} {}{}", if mock.method.is_empty() { "ANY" @@ -1502,8 +1388,8 @@ fn render_intercept(state: &Rc, column: >k::Box, row: &protocol::Interc }, mock.subject, mock.path - )) - .subtitle(format!( + ), + format!( "answers {}{}{}{}", mock.status, if mock.refusal { ", as a refusal" } else { "" }, @@ -1517,8 +1403,8 @@ fn render_intercept(state: &Rc, column: >k::Box, row: &protocol::Interc } else { " — switched off" } - )) - .build(); + ), + ); let forget = gtk::Button::builder() .icon_name("user-trash-symbolic") .tooltip_text("Forget this answer") @@ -1552,19 +1438,15 @@ fn render_intercept(state: &Rc, column: >k::Box, row: &protocol::Interc ) .build(); if row.guardrails.is_empty() { - guarded.add( - &adw::ActionRow::builder() - .use_markup(false) - .title("None") - .subtitle("`flowlightd guardrail --tool write_file --agent claude` writes one.") - .build(), - ); + guarded.add(&literal_row_with( + "None", + "`flowlightd guardrail --tool write_file --agent claude` writes one.", + )); } for guardrail in &row.guardrails { - let line = adw::ActionRow::builder() - .use_markup(false) - .title(guardrail.title.clone()) - .subtitle(format!( + let line = literal_row_with( + guardrail.title.clone(), + format!( "{}{}", match guardrail.hits { 0 => "has never refused anything".to_owned(), @@ -1576,8 +1458,8 @@ fn render_intercept(state: &Rc, column: >k::Box, row: &protocol::Interc } else { " — switched off" } - )) - .build(); + ), + ); let forget = gtk::Button::builder() .icon_name("user-trash-symbolic") .tooltip_text("Forget this guardrail") @@ -1648,26 +1530,18 @@ fn render_intercept(state: &Rc, column: >k::Box, row: &protocol::Interc certificate is supposed to do. `sudo flowlightd trust` says what to tell each thing.", ) .build(); - certificate.add( - &adw::ActionRow::builder() - .use_markup(false) - .title("Certificate") - .subtitle( - row.intercept - .certificate - .clone() - .unwrap_or_else(|| "not made yet".to_owned()), - ) - .build(), - ); + certificate.add(&literal_row_with( + "Certificate", + row.intercept + .certificate + .clone() + .unwrap_or_else(|| "not made yet".to_owned()), + )); if let Some(bundle) = &row.intercept.bundle { - certificate.add( - &adw::ActionRow::builder() - .use_markup(false) - .title("This machine's roots plus it") - .subtitle(bundle.clone()) - .build(), - ); + certificate.add(&literal_row_with( + "This machine's roots plus it", + bundle.clone(), + )); } column.append(&certificate);