Skip to main content

max / alloy

Close the locale row when only one locale is installed The image ships glibc-minimal-langpack and no langpacks, so /usr/lib/locale holds C.utf8 alone and localectl has one locale to list. The gate only fired on an empty vocabulary, so the row rendered settable offering the locale already in force. Gate on the count instead, the way the theme row already does, and name the installed locale rather than reporting a bare absence. The count is the gate rather than a langpack probe, so layering one in reopens the row with no change here. The keymap row shares the localectl front but not the defect: kbd-misc carries the full keymap set, so it lists what is really there.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-31 16:23 UTC
Signed with PGP, not checked
Commit: a45f5a8cfc67d62042d54d50260c5ae16a2bd220
Parent: 86830df
3 files changed, +98 insertions, -12 deletions
M docs/CONSOLE.md +1 -1
@@ -109,7 +109,7 @@
109 109
110 110 The thesis at the top of this document, built. One view, two tabs, and the same form under both:
111 111
112 - - **System**, first, because it is the one a user goes looking for. General settings held as live state: time (zone, NTP), hostname, locale and keymap, theme. Time, hostname and locale are built, fronting `timedatectl`, `hostnamectl` and `localectl`; theme is the one left. Each writing row runs exactly one command, and every one of those is in the five actions the shipped polkit rule grants, so none of them prompts and none can reach an action deliberately left prompting. A row whose front did not answer, or whose vocabulary came back empty, is shown and not settable and says why: hiding it would answer "where do I set this" with silence, and offering it would promise a command that is not there. Its rows commit as they are edited rather than at Ctrl-S, and the form reads that from the bind rather than from which tab it is on. Each is a handful of rows over one front with machine-readable output, which is the bar `net` (`nmcli`) and `audio` (`pactl -f json`) were already held to.
112 + - **System**, first, because it is the one a user goes looking for. General settings held as live state: time (zone, NTP), hostname, locale and keymap, theme. Time, hostname and locale are built, fronting `timedatectl`, `hostnamectl` and `localectl`; theme is the one left. Each writing row runs exactly one command, and every one of those is in the five actions the shipped polkit rule grants, so none of them prompts and none can reach an action deliberately left prompting. A row whose front did not answer, or whose vocabulary came back empty, is shown and not settable and says why: hiding it would answer "where do I set this" with silence, and offering it would promise a command that is not there. A vocabulary of one closes the row on the same grounds, since the single entry is whatever is already in force and picking it changes nothing. The locale row is the case that reaches users: the image carries no glibc langpacks, so `localectl` has one locale to list, and the row names it and says that adding a langpack adds a choice. The gate is the count rather than a langpack probe, so the row reopens by itself once one is layered in. Its rows commit as they are edited rather than at Ctrl-S, and the form reads that from the bind rather than from which tab it is on. Each is a handful of rows over one front with machine-readable output, which is the bar `net` (`nmcli`) and `audio` (`pactl -f json`) were already held to.
113 113 - **Applications**, the adopted stack's config files, grouped behind the app each one configures rather than presented as file paths. The user picks **rio**, not `~/.config/rio/config.toml`; left pane lists the apps a schema ships for, right pane is the form for the selected one, reusing `audio`'s two-pane routing layout. An app with no schema (sway, whose i3-style syntax is neither TOML nor KDL) still appears in the list and opens the text-edit fallback, because someone looking for "where do I configure sway" should find an answer rather than an absence.
114 114
115 115 Display and power/idle are deliberately not here. `alloy display` is a verb of its own, now shipped, and idle behaviour belongs with it; folding them in would duplicate a screen rather than unify one.
@@ -128,8 +128,12 @@
128 128
129 129 Both are rows on the System tab of `alloy settings`, fronting `localectl`.
130 130
131 - One caveat: the image carries a minimal set of glibc language packs. Setting a
132 - locale the image cannot deliver records your choice and gives you no
133 - translations, because the data is not there. If you need a locale beyond
134 - English, layer its `glibc-langpack-*` package (chapter 7) or add it to the
135 - Containerfile and rebuild.
131 + The keymap row works as you would expect: the image carries the full `kbd`
132 + keymap set, so the row lists what is really there.
133 +
134 + Locale is the one to know about. The image ships `glibc-minimal-langpack` and no
135 + language packs at all, so the only locale installed is `C.UTF-8` and that is
136 + what the system formats and sorts in. The locale row shows this and is not
137 + settable, because there is nothing to pick between. To get another locale, layer
138 + its `glibc-langpack-*` package (chapter 7) or add it to the Containerfile and
139 + rebuild; the row turns settable on its own once a second locale is present.
@@ -55,6 +55,13 @@
55 55 //! it would answer "where do I set this" with silence, and offering it would
56 56 //! promise a command that is not there.
57 57 //!
58 + //! A vocabulary of one closes the row on the same grounds. The single entry is
59 + //! whatever is already in force, so there is nothing to pick between, and the
60 + //! row would be offering a change it cannot make. The shipped image reaches
61 + //! this through the locale row: it carries no langpacks, so `localectl` has one
62 + //! locale to list. The theme row has always read it this way, since a search
63 + //! path that found no themes still leaves `system` behind.
64 + //!
58 65 //! See wiki note `alloy-privilege`.
59 66 //!
60 67 //! <!-- wiki: alloy-settings -->
@@ -199,14 +206,35 @@
199 206 let quiet = !self.host.answered;
200 207 set(self.field_mut(HOSTNAME), &note, quiet);
201 208
209 + // One locale installed is not a vocabulary, it is the locale already in
210 + // force, and a row offering only what is already set promises a change
211 + // it cannot make. The shipped image is exactly that case: it carries
212 + // `glibc-minimal-langpack` and no langpacks, so `/usr/lib/locale` holds
213 + // `C.utf8` alone and `localectl` has one entry to report. So the row
214 + // names the installed locale and what would add another, rather than
215 + // saying "unavailable" and leaving the cause to be guessed at.
216 + //
217 + // Gating on the count rather than on a langpack probe is what makes it
218 + // reopen by itself: install a langpack and the row is settable with no
219 + // change here. `localectl` reads the archive and `/usr/lib/locale`, so
220 + // it is already reporting what is present rather than what glibc knows.
221 + let installed = self.choices(LOCALE);
202 222 let (note, closed) = if !self.locale.answered {
203 - ("localectl did not answer.", true)
204 - } else if self.choices(LOCALE).is_empty() {
205 - ("This machine lists no locales.", true)
223 + ("localectl did not answer.".to_string(), true)
224 + } else if installed.is_empty() {
225 + ("This machine lists no locales.".to_string(), true)
226 + } else if let [only] = installed {
227 + (
228 + format!(
229 + "Only {} is installed here. Adding a langpack adds a choice.",
230 + only.value
231 + ),
232 + true,
233 + )
206 234 } else {
207 - ("System locale. Applies to new sessions.", false)
235 + ("System locale. Applies to new sessions.".to_string(), false)
208 236 };
209 - set(self.field_mut(LOCALE), note, closed);
237 + set(self.field_mut(LOCALE), &note, closed);
210 238
211 239 let (note, closed) = if !self.locale.answered {
212 240 ("localectl did not answer.", true)
@@ -819,7 +847,7 @@
819 847 sections: sections(),
820 848 fields: rows(
821 849 Vec::new(),
822 - parse_list("en_US.UTF-8\n"),
850 + parse_list("en_US.UTF-8\nen_GB.UTF-8\n"),
823 851 Vec::new(),
824 852 theme_choices(),
825 853 ),
@@ -837,6 +865,60 @@
837 865 );
838 866 }
839 867
868 + /// Build a bind whose locale vocabulary is exactly `locales`.
869 + fn with_locales(locales: &str) -> SystemBind {
870 + let mut bind = SystemBind {
871 + sections: sections(),
872 + fields: rows(
873 + Vec::new(),
874 + parse_list(locales),
875 + parse_list("us\n"),
876 + theme_choices(),
877 + ),
878 + time: parse_show(SHOW),
879 + host: parse_host(HOST),
880 + locale: parse_locale(LOCALE_STATUS),
881 + };
882 + bind.annotate();
883 + bind
884 + }
885 +
886 + // The shipped image's real state: `glibc-minimal-langpack` and no
887 + // langpacks, so `/usr/lib/locale` holds `C.utf8` alone and `localectl` has
888 + // one locale to report. The row is shown, closed, and names the locale that
889 + // is installed rather than reporting a bare absence.
890 + #[test]
891 + fn one_installed_locale_closes_the_row_and_says_which() {
892 + let mut bind = with_locales("C.UTF-8\n");
893 +
894 + assert!(bind.field(LOCALE).unwrap().readonly);
895 + assert!(
896 + help(&bind, LOCALE).contains("Only C.UTF-8 is installed here"),
897 + "{}",
898 + help(&bind, LOCALE),
899 + );
900 + assert!(
901 + bind.commit(LOCALE, Value::String("C.UTF-8".into()))
902 + .is_err(),
903 + "a closed row takes no write"
904 + );
905 + }
906 +
907 + // The half that matters for the next reader: the gate is the count, not a
908 + // langpack probe, so installing a langpack reopens the row on its own and
909 + // nothing here needs revisiting.
910 + #[test]
911 + fn a_second_langpack_reopens_the_locale_row() {
912 + let bind = with_locales("C.UTF-8\nen_US.UTF-8\n");
913 +
914 + assert!(!bind.field(LOCALE).unwrap().readonly);
915 + assert!(
916 + help(&bind, LOCALE).contains("Applies to new sessions"),
917 + "{}",
918 + help(&bind, LOCALE),
919 + );
920 + }
921 +
840 922 // A machine that has never had /etc/hostname still shows the name it
841 923 // answers to, and the help line says what setting it would do.
842 924 #[test]