diff --git a/sensors/contrast.vala b/sensors/contrast.vala
new file mode 100644
index 0000000..2f0481a
--- /dev/null
+++ b/sensors/contrast.vala
@@ -0,0 +1,228 @@
+namespace SensorsContrast {
+ public const double TARGET_PREFERRED = 7.0;
+ public const double TARGET_MINIMUM = 4.5;
+
+ public struct Color {
+ public double r;
+ public double g;
+ public double b;
+ public double a;
+
+ public Color(double r, double g, double b, double a = 1.0) {
+ this.r = r;
+ this.g = g;
+ this.b = b;
+ this.a = a;
+ }
+
+ public string to_hex() {
+ return "#%02x%02x%02x".printf(to_byte(r), to_byte(g), to_byte(b));
+ }
+ }
+
+ private uint to_byte(double v) {
+ return (uint) Math.round(v.clamp(0.0, 1.0) * 255.0);
+ }
+
+ private double linearize(double c) {
+ return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
+ }
+
+ public double relative_luminance(Color c) {
+ return 0.2126 * linearize(c.r) + 0.7152 * linearize(c.g) + 0.0722 * linearize(c.b);
+ }
+
+ public double contrast_ratio(Color a, Color b) {
+ double la = relative_luminance(a);
+ double lb = relative_luminance(b);
+ double hi = double.max(la, lb);
+ double lo = double.min(la, lb);
+ return (hi + 0.05) / (lo + 0.05);
+ }
+
+ public Color composite(Color fg, Color under) {
+ double a = fg.a.clamp(0.0, 1.0);
+ return Color(fg.r * a + under.r * (1.0 - a),
+ fg.g * a + under.g * (1.0 - a),
+ fg.b * a + under.b * (1.0 - a), 1.0);
+ }
+
+ public Color mix(Color from, Color to, double t) {
+ return Color(from.r + (to.r - from.r) * t,
+ from.g + (to.g - from.g) * t,
+ from.b + (to.b - from.b) * t, 1.0);
+ }
+
+ public Color near_black() {
+ return Color(0.08, 0.08, 0.08);
+ }
+
+ public Color near_white() {
+ return Color(0.96, 0.96, 0.96);
+ }
+
+ public Color scrim_for(Color theme_bg) {
+ bool dark = relative_luminance(theme_bg) < 0.4;
+ return dark ? Color(0.09, 0.09, 0.10) : Color(0.96, 0.96, 0.97);
+ }
+
+ public Color neutral_for(Color bg) {
+ Color dark = near_black();
+ Color light = near_white();
+ Color best = contrast_ratio(dark, bg) >= contrast_ratio(light, bg) ? dark : light;
+ if (contrast_ratio(best, bg) >= TARGET_PREFERRED) {
+ return best;
+ }
+ Color black = Color(0.0, 0.0, 0.0);
+ Color white = Color(1.0, 1.0, 1.0);
+ return contrast_ratio(black, bg) >= contrast_ratio(white, bg) ? black : white;
+ }
+
+ private void to_hsl(Color c, out double h, out double s, out double l) {
+ double mx = double.max(c.r, double.max(c.g, c.b));
+ double mn = double.min(c.r, double.min(c.g, c.b));
+ double d = mx - mn;
+ l = (mx + mn) / 2.0;
+ h = 0.0;
+ s = 0.0;
+ if (d < 1e-9) {
+ return;
+ }
+ s = d / (1.0 - Math.fabs(2.0 * l - 1.0));
+ if (mx == c.r) {
+ h = ((c.g - c.b) / d) % 6.0;
+ } else if (mx == c.g) {
+ h = (c.b - c.r) / d + 2.0;
+ } else {
+ h = (c.r - c.g) / d + 4.0;
+ }
+ h = h * 60.0;
+ if (h < 0.0) {
+ h += 360.0;
+ }
+ }
+
+ private Color from_hsl(double h, double s, double l) {
+ double c = (1.0 - Math.fabs(2.0 * l - 1.0)) * s;
+ double x = c * (1.0 - Math.fabs((h / 60.0) % 2.0 - 1.0));
+ double m = l - c / 2.0;
+ double r = 0.0, g = 0.0, b = 0.0;
+ if (h < 60.0) { r = c; g = x; }
+ else if (h < 120.0) { r = x; g = c; }
+ else if (h < 180.0) { g = c; b = x; }
+ else if (h < 240.0) { g = x; b = c; }
+ else if (h < 300.0) { r = x; b = c; }
+ else { r = c; b = x; }
+ return Color(r + m, g + m, b + m);
+ }
+
+ public Color ensure_contrast(Color fg, Color bg, double target) {
+ if (contrast_ratio(fg, bg) >= target) {
+ return Color(fg.r, fg.g, fg.b);
+ }
+ double h, s, l;
+ to_hsl(fg, out h, out s, out l);
+ double extreme = contrast_ratio(near_black(), bg) >= contrast_ratio(near_white(), bg)
+ ? 0.0 : 1.0;
+ if (contrast_ratio(from_hsl(h, s, extreme), bg) < target) {
+ return from_hsl(h, s, extreme);
+ }
+ double near = l;
+ double far = extreme;
+ for (int i = 0; i < 24; i++) {
+ double mid = (near + far) / 2.0;
+ if (contrast_ratio(from_hsl(h, s, mid), bg) >= target) {
+ far = mid;
+ } else {
+ near = mid;
+ }
+ }
+ return from_hsl(h, s, far);
+ }
+
+ public Color best_contrast(Color fg, Color bg) {
+ Color strong = ensure_contrast(fg, bg, TARGET_PREFERRED);
+ if (contrast_ratio(strong, bg) >= TARGET_PREFERRED) {
+ return strong;
+ }
+ return ensure_contrast(fg, bg, TARGET_MINIMUM);
+ }
+
+ public Color muted_for(Color neutral, Color bg) {
+ Color result = neutral;
+ for (int i = 1; i <= 20; i++) {
+ Color candidate = mix(neutral, bg, i / 40.0);
+ if (contrast_ratio(candidate, bg) < 5.0) {
+ break;
+ }
+ result = candidate;
+ }
+ return result;
+ }
+
+ public Color fallback_for(string role, bool dark_background) {
+ switch (role) {
+ case "error":
+ return dark_background ? Color(1.0, 0.45, 0.42) : Color(0.70, 0.06, 0.08);
+ case "warning":
+ return dark_background ? Color(1.0, 0.72, 0.25) : Color(0.60, 0.33, 0.0);
+ case "accent":
+ return dark_background ? Color(0.55, 0.75, 1.0) : Color(0.05, 0.30, 0.72);
+ default:
+ return dark_background ? Color(0.45, 0.90, 0.55) : Color(0.05, 0.42, 0.15);
+ }
+ }
+
+ public Color median_by_luminance(Color[] samples) {
+ if (samples.length == 0) {
+ return Color(0.5, 0.5, 0.5);
+ }
+ int n = samples.length;
+ double[] lum = new double[n];
+ int[] order = new int[n];
+ for (int i = 0; i < n; i++) {
+ lum[i] = relative_luminance(samples[i]);
+ order[i] = i;
+ }
+ for (int i = 1; i < n; i++) {
+ int key = order[i];
+ int j = i - 1;
+ while (j >= 0 && lum[order[j]] > lum[key]) {
+ order[j + 1] = order[j];
+ j--;
+ }
+ order[j + 1] = key;
+ }
+ return samples[order[n / 2]];
+ }
+
+ public class Palette : Object {
+ public Color bg;
+ public Color neutral;
+ public Color muted;
+ public Color ok;
+ public Color hot;
+ public Color crit;
+ public Color accent;
+
+ public Palette(Color bg, Color? theme_ok, Color? theme_hot, Color? theme_crit, Color? theme_accent) {
+ this.bg = bg;
+ bool dark = relative_luminance(bg) < 0.18;
+ neutral = neutral_for(bg);
+ muted = muted_for(neutral, bg);
+ ok = best_contrast(theme_ok ?? fallback_for("success", dark), bg);
+ hot = best_contrast(theme_hot ?? fallback_for("warning", dark), bg);
+ crit = best_contrast(theme_crit ?? fallback_for("error", dark), bg);
+ accent = best_contrast(theme_accent ?? fallback_for("accent", dark), bg);
+ }
+
+ public bool equals(Palette other) {
+ return bg.to_hex() == other.bg.to_hex()
+ && neutral.to_hex() == other.neutral.to_hex()
+ && ok.to_hex() == other.ok.to_hex()
+ && hot.to_hex() == other.hot.to_hex()
+ && crit.to_hex() == other.crit.to_hex()
+ && accent.to_hex() == other.accent.to_hex();
+ }
+ }
+}
diff --git a/sensors/meson.build b/sensors/meson.build
index 859cf03..492ff3e 100644
--- a/sensors/meson.build
+++ b/sensors/meson.build
@@ -1,5 +1,6 @@
shared_module('sensors',
'sensors.vala',
+ 'contrast.vala',
dependencies: [dependency('gtk4'), libsingularity_dep, system_dep, dependency('libpeas-2'), meson.get_compiler('c').find_library('m')],
c_args: ['-DGETTEXT_PACKAGE="singularity-desktop"'],
install: true,
@@ -13,3 +14,10 @@ configure_file(
install: true,
install_dir: get_option('libdir') / 'singularity/plugins/sensors'
)
+
+contrast_test = executable('sensors-contrast-test',
+ 'contrast.vala', 'tests/contrast-test.vala',
+ dependencies: [dependency('glib-2.0'), dependency('gobject-2.0'), meson.get_compiler('c').find_library('m')],
+ install: false
+)
+test('sensors-contrast', contrast_test)
diff --git a/sensors/sensors.vala b/sensors/sensors.vala
index 7003778..deb75dc 100644
--- a/sensors/sensors.vala
+++ b/sensors/sensors.vala
@@ -72,6 +72,15 @@ class SensorsIndicator : Gtk.Box {
private int left_row_count = 0;
private int right_row_count = 0;
private static Gtk.CssProvider? compact_rows_provider = null;
+ private Gtk.CssProvider? contrast_provider = null;
+ private SensorsContrast.Palette panel_palette;
+ private SensorsContrast.Palette popup_palette;
+ private uint contrast_source = 0;
+ private bool panel_backed = false;
+ private bool popup_backed = false;
+ private Gtk.WidgetPaintable? watched_paintable = null;
+ private ulong watched_handler = 0;
+ private ulong[] settings_handlers = {};
private SensorMonitor monitor;
private bool show_frequency = true;
private bool show_utilization = true;
@@ -114,6 +123,8 @@ class SensorsIndicator : Gtk.Box {
// glance shows WHICH figure needs attention, not just that one
// does.
summary_label.use_markup = true;
+ panel_palette = build_palette(theme_background());
+ popup_palette = panel_palette;
button = new MenuButton();
button.add_css_class("flat");
@@ -132,6 +143,7 @@ class SensorsIndicator : Gtk.Box {
compact_rows_provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION);
}
detail_box = new Box(Orientation.VERTICAL, 4);
+ detail_box.add_css_class("sensors-detail");
detail_box.margin_top = 10;
detail_box.margin_bottom = 10;
detail_box.margin_start = 12;
@@ -189,8 +201,21 @@ class SensorsIndicator : Gtk.Box {
if (popover.visible) {
configure_detail_layout();
monitor.refresh();
+ schedule_contrast_refresh();
}
});
+ contrast_provider = new Gtk.CssProvider();
+ Gtk.StyleContext.add_provider_for_display(get_display(),
+ contrast_provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION + 1);
+ var gtk_settings = Gtk.Settings.get_default();
+ if (gtk_settings != null) {
+ settings_handlers += gtk_settings.notify["gtk-theme-name"].connect(schedule_contrast_refresh);
+ settings_handlers += gtk_settings.notify["gtk-application-prefer-dark-theme"].connect(schedule_contrast_refresh);
+ settings_handlers += gtk_settings.notify["gtk-interface-color-scheme"].connect(schedule_contrast_refresh);
+ }
+ map.connect(watch_panel_background);
+ unmap.connect(unwatch_panel_background);
+ apply_contrast_css();
monitor = new_sensor_monitor();
util = new UtilizationMonitor();
@@ -281,6 +306,22 @@ class SensorsIndicator : Gtk.Box {
}
public override void dispose() {
+ var gtk_settings = Gtk.Settings.get_default();
+ if (gtk_settings != null) {
+ foreach (ulong id in settings_handlers) {
+ SignalHandler.disconnect(gtk_settings, id);
+ }
+ }
+ settings_handlers = {};
+ unwatch_panel_background();
+ if (contrast_source != 0) {
+ Source.remove(contrast_source);
+ contrast_source = 0;
+ }
+ if (contrast_provider != null) {
+ Gtk.StyleContext.remove_provider_for_display(get_display(), contrast_provider);
+ contrast_provider = null;
+ }
monitor.updated.disconnect(on_updated);
monitor.stop();
util.updated.disconnect(on_updated);
@@ -357,40 +398,214 @@ class SensorsIndicator : Gtk.Box {
return show_utilization && util.memory_fraction >= 0.0;
}
- /**
- * Resolve a NAMED theme colour (e.g. "success_color") to a hex
- * string for Pango markup.
- *
- * Markup spans take a literal colour, not a CSS variable, so the
- * value has to be looked up at render time rather than written once
- * -- this is what keeps it honest across a light/dark theme switch
- * instead of baking in a colour that only happened to be right when
- * the code was written. Falls back to the theme's plain text colour
- * if the named token is ever missing, so a lookup failure degrades
- * to unstyled text rather than invalid markup.
- */
- private string theme_color_hex(string color_name) {
- // lookup_color lives on StyleContext, not on Widget directly
- // (deprecated since GTK 4.10, but still the working path -- no
- // non-deprecated replacement exists for resolving a NAMED CSS
- // colour at runtime, only get_color() for the resolved `color`
- // property itself).
+ private SensorsContrast.Color theme_background() {
var style = summary_label.get_style_context();
Gdk.RGBA rgba;
- if (!style.lookup_color(color_name, out rgba)) {
- if (!style.lookup_color("text_color", out rgba)) {
- return "#ffffff";
+ if (style.lookup_color("window_bg_color", out rgba)
+ || style.lookup_color("theme_bg_color", out rgba)) {
+ return SensorsContrast.Color(rgba.red, rgba.green, rgba.blue, 1.0);
+ }
+ var gtk_settings = Gtk.Settings.get_default();
+ bool dark = gtk_settings != null && gtk_settings.gtk_application_prefer_dark_theme;
+ return dark ? SensorsContrast.Color(0.14, 0.14, 0.14) : SensorsContrast.Color(0.96, 0.96, 0.96);
+ }
+
+ private SensorsContrast.Color? lookup_theme_color(string name) {
+ Gdk.RGBA rgba;
+ if (!summary_label.get_style_context().lookup_color(name, out rgba)) {
+ return null;
+ }
+ return SensorsContrast.Color(rgba.red, rgba.green, rgba.blue, 1.0);
+ }
+
+ private SensorsContrast.Palette build_palette(SensorsContrast.Color bg) {
+ return new SensorsContrast.Palette(bg, lookup_theme_color("success_color"),
+ lookup_theme_color("warning_color"), lookup_theme_color("error_color"),
+ lookup_theme_color("accent_color"));
+ }
+
+ private SensorsContrast.Color? sample_background(Gtk.Widget target, int pad, out bool translucent) {
+ translucent = false;
+ var native = target.get_native();
+ var native_widget = native as Gtk.Widget;
+ if (native == null || native_widget == null || !target.get_mapped()) {
+ return null;
+ }
+ Graphene.Rect inner;
+ if (!target.compute_bounds(native_widget, out inner)) {
+ return null;
+ }
+ var renderer = native.get_renderer();
+ if (renderer == null) {
+ return null;
+ }
+ float native_w = native_widget.get_width();
+ float native_h = native_widget.get_height();
+ float x0 = float.max(0.0f, inner.origin.x - pad);
+ float y0 = float.max(0.0f, inner.origin.y - pad);
+ float x1 = float.min(native_w, inner.origin.x + inner.size.width + pad);
+ float y1 = float.min(native_h, inner.origin.y + inner.size.height + pad);
+ if (x1 - x0 < 2.0f || y1 - y0 < 2.0f) {
+ return null;
+ }
+ var snapshot = new Gtk.Snapshot();
+ new Gtk.WidgetPaintable(native_widget).snapshot(snapshot, native_w, native_h);
+ Gsk.RenderNode? node = snapshot.free_to_node();
+ if (node == null) {
+ return null;
+ }
+ var viewport = Graphene.Rect().init(x0, y0, x1 - x0, y1 - y0);
+ Gdk.Texture texture = renderer.render_texture(node, viewport);
+ int tw = texture.get_width();
+ int th = texture.get_height();
+ var downloader = new Gdk.TextureDownloader(texture);
+ downloader.set_format(Gdk.MemoryFormat.R8G8B8A8);
+ size_t stride;
+ Bytes bytes = downloader.download_bytes(out stride);
+ unowned uint8[] data = bytes.get_data();
+ double sx = tw / (double) (x1 - x0);
+ double sy = th / (double) (y1 - y0);
+ int ix0 = (int) ((inner.origin.x - x0) * sx);
+ int iy0 = (int) ((inner.origin.y - y0) * sy);
+ int ix1 = (int) ((inner.origin.x + inner.size.width - x0) * sx);
+ int iy1 = (int) ((inner.origin.y + inner.size.height - y0) * sy);
+ var under = theme_background();
+ var samples = new SensorsContrast.Color[0];
+ double alpha_sum = 0.0;
+ int step = int.max(1, (tw * th) / 400);
+ for (int i = 0; i < tw * th; i += step) {
+ int px = i % tw;
+ int py = i / tw;
+ if (px >= ix0 && px < ix1 && py >= iy0 && py < iy1) {
+ continue;
}
+ size_t o = py * stride + px * 4;
+ if (o + 3 >= data.length) {
+ continue;
+ }
+ alpha_sum += data[o + 3] / 255.0;
+ samples += SensorsContrast.composite(
+ SensorsContrast.Color(data[o] / 255.0, data[o + 1] / 255.0,
+ data[o + 2] / 255.0, data[o + 3] / 255.0), under);
+ }
+ if (samples.length == 0) {
+ return null;
+ }
+ translucent = alpha_sum / samples.length < 0.95;
+ return SensorsContrast.median_by_luminance(samples);
+ }
+
+ private void schedule_contrast_refresh() {
+ if (contrast_source != 0) {
+ return;
}
- return "#%02x%02x%02x".printf(
- (uint) Math.round(rgba.red * 255),
- (uint) Math.round(rgba.green * 255),
- (uint) Math.round(rgba.blue * 255));
+ contrast_source = Timeout.add(150, () => {
+ contrast_source = 0;
+ refresh_contrast();
+ return Source.REMOVE;
+ });
}
- /** One coloured "dot value" segment for the compact chip. */
- private string markup_segment(string color_hex, string text) {
- return "\u25cf %s".printf(color_hex, Markup.escape_text(text));
+ private SensorsContrast.Color resolve_surface(Gtk.Widget target, int pad, out bool scrim) {
+ var fallback = theme_background();
+ bool translucent;
+ var sampled = sample_background(target, pad, out translucent);
+ scrim = translucent;
+ if (translucent) {
+ return SensorsContrast.scrim_for(fallback);
+ }
+ return sampled ?? fallback;
+ }
+
+ private void refresh_contrast() {
+ bool panel_scrim;
+ var new_panel = build_palette(resolve_surface(button, 6, out panel_scrim));
+ var new_popup = new_panel;
+ bool popup_scrim = false;
+ Popover? popover = button.popover;
+ if (popover != null && popover.visible) {
+ new_popup = build_palette(resolve_surface(detail_box, 8, out popup_scrim));
+ } else {
+ new_popup = build_palette(theme_background());
+ }
+ bool changed = !new_panel.equals(panel_palette) || !new_popup.equals(popup_palette)
+ || panel_scrim != panel_backed || popup_scrim != popup_backed;
+ bool panel_changed = !new_panel.equals(panel_palette);
+ panel_palette = new_panel;
+ popup_palette = new_popup;
+ panel_backed = panel_scrim;
+ popup_backed = popup_scrim;
+ if (!changed) {
+ return;
+ }
+ apply_contrast_css();
+ if (panel_changed) {
+ on_updated();
+ } else if (popover != null && popover.visible) {
+ rebuild_details();
+ }
+ }
+
+ private static string scrim_css(SensorsContrast.Palette p) {
+ return "background-color: rgba(%d, %d, %d, 0.92); border-radius: 8px; ".printf(
+ (int) Math.round(p.bg.r * 255), (int) Math.round(p.bg.g * 255), (int) Math.round(p.bg.b * 255));
+ }
+
+ private void apply_contrast_css() {
+ if (contrast_provider == null) {
+ return;
+ }
+ var pn = panel_palette;
+ var pp = popup_palette;
+ contrast_provider.load_from_string(
+ ".sensors-summary { color: %s; opacity: 1; %s}\n".printf(
+ pn.neutral.to_hex(), panel_backed ? scrim_css(pn) + "padding: 2px 8px; " : "") +
+ ".sensors-detail { %s}\n".printf(popup_backed ? scrim_css(pp) : "") +
+ ".sensors-detail label { color: %s; opacity: 1; }\n".printf(pp.neutral.to_hex()) +
+ ".sensors-detail .sensors-muted, .sensors-detail .sensors-muted label { color: %s; opacity: 1; }\n".printf(pp.muted.to_hex()) +
+ ".sensors-detail label.sensors-hot { color: %s; font-weight: 600; opacity: 1; }\n".printf(pp.hot.to_hex()) +
+ ".sensors-detail label.sensors-crit { color: %s; font-weight: 800; opacity: 1; }\n".printf(pp.crit.to_hex()));
+ }
+
+ private void watch_panel_background() {
+ unwatch_panel_background();
+ var native = get_native() as Gtk.Widget;
+ if (native == null) {
+ return;
+ }
+ watched_paintable = new Gtk.WidgetPaintable(native);
+ watched_handler = watched_paintable.invalidate_contents.connect(schedule_contrast_refresh);
+ schedule_contrast_refresh();
+ }
+
+ private void unwatch_panel_background() {
+ if (watched_paintable != null && watched_handler != 0) {
+ watched_paintable.disconnect(watched_handler);
+ }
+ watched_paintable = null;
+ watched_handler = 0;
+ }
+
+ private string markup_segment(SensorsContrast.Color color, string glyph, string text, bool bold = false) {
+ string weight = bold ? " weight='bold'" : "";
+ return "%s %s".printf(color.to_hex(), weight, glyph, Markup.escape_text(text));
+ }
+
+ private SensorsContrast.Color severity_color(Severity severity) {
+ switch (severity) {
+ case Severity.CRITICAL: return panel_palette.crit;
+ case Severity.HOT: return panel_palette.hot;
+ case Severity.WARM: return panel_palette.neutral;
+ default: return panel_palette.ok;
+ }
+ }
+
+ private static string severity_glyph(Severity severity) {
+ switch (severity) {
+ case Severity.CRITICAL: return "\u25c6";
+ case Severity.HOT: return "\u25b2";
+ default: return "\u25cf";
+ }
}
private static int percent_of(double fraction) {
@@ -517,15 +732,17 @@ class SensorsIndicator : Gtk.Box {
StringBuilder markup = new StringBuilder();
if (primary >= 0) {
- markup.append(markup_segment(theme_color_hex(severity_color_name(primary_severity)),
- format_celsius(primary)));
+ markup.append(markup_segment(severity_color(primary_severity),
+ severity_glyph(primary_severity),
+ format_celsius(primary),
+ primary_severity == Severity.CRITICAL));
}
if (show_frequency && monitor.cpu_khz > 0) {
if (markup.len > 0) markup.append(" ");
// Clock speed is informational, never an alarm colour --
// same reasoning as CPU below: running near the maximum is
// the CPU doing its job, not a problem to flag red.
- markup.append(markup_segment(theme_color_hex("accent_color"), format_clock(monitor.cpu_khz)));
+ markup.append(markup_segment(panel_palette.accent, "\u25cf", format_clock(monitor.cpu_khz)));
}
// Utilisation in the compact chip, not only in the popover.
//
@@ -542,14 +759,16 @@ class SensorsIndicator : Gtk.Box {
// user to ignore the colour that does mean something --
// the same reasoning the popover's Clocks section and
// capacity_severity() already document.
- markup.append(markup_segment(theme_color_hex("accent_color"),
+ markup.append(markup_segment(panel_palette.accent, "\u25cf",
_("CPU %d%%").printf(percent_of(util.cpu_fraction))));
}
if (util.memory_fraction >= 0.0) {
if (markup.len > 0) markup.append(" ");
Severity mem_severity = capacity_severity(util.memory_fraction);
- markup.append(markup_segment(theme_color_hex(severity_color_name(mem_severity)),
- _("MEM %d%%").printf(percent_of(util.memory_fraction))));
+ markup.append(markup_segment(severity_color(mem_severity),
+ severity_glyph(mem_severity),
+ _("MEM %d%%").printf(percent_of(util.memory_fraction)),
+ mem_severity == Severity.CRITICAL));
}
}
summary_label.label = markup.str;
@@ -637,7 +856,7 @@ class SensorsIndicator : Gtk.Box {
Button toggle = new Button();
toggle.has_frame = false;
toggle.add_css_class("flat");
- toggle.add_css_class("dim-label");
+ toggle.add_css_class("sensors-muted");
toggle.label = sensors_grouped ? _("Grouped") : _("Ungrouped");
toggle.clicked.connect(() => {
sensors_grouped = !sensors_grouped;
@@ -666,31 +885,12 @@ class SensorsIndicator : Gtk.Box {
* the first step of the ramp. Colour is spent only where it means
* something: dim, plain, amber, red.
*/
- /**
- * Severity -> a named theme colour, for markup (not a CSS class).
- *
- * NORMAL reads as success (a calm "this is fine" green) rather than
- * plain text, matching the standard status-dashboard convention the
- * graphical chip is going for. WARM stays neutral -- the original
- * design's severity_css() below also treats WARM as not yet worth
- * flagging, and this mirrors that rather than inventing a new
- * threshold.
- */
- private string severity_color_name(Severity severity) {
- switch (severity) {
- case Severity.CRITICAL: return "error_color";
- case Severity.HOT: return "warning_color";
- case Severity.WARM: return "text_color";
- default: return "success_color";
- }
- }
-
private static string? severity_css(Severity severity) {
switch (severity) {
- case Severity.CRITICAL: return "error";
- case Severity.HOT: return "warning";
+ case Severity.CRITICAL: return "sensors-crit";
+ case Severity.HOT: return "sensors-hot";
case Severity.WARM: return null;
- default: return "dim-label";
+ default: return "sensors-muted";
}
}
@@ -740,7 +940,9 @@ class SensorsIndicator : Gtk.Box {
double fill_w = double.max(h, w * f);
double r, g, b;
heat_rgb(f, out r, out g, out b);
- cr.set_source_rgb(r, g, b);
+ var fill = SensorsContrast.ensure_contrast(SensorsContrast.Color(r, g, b),
+ popup_palette.bg, 3.0);
+ cr.set_source_rgb(fill.r, fill.g, fill.b);
rounded_rect(cr, 0, 0, fill_w, h, radius);
cr.fill();
});
@@ -780,7 +982,11 @@ class SensorsIndicator : Gtk.Box {
row.append(make_heat_bar(heat));
}
- Label value_label = new Label(value);
+ string shown = value;
+ if (severity == Severity.CRITICAL || severity == Severity.HOT) {
+ shown = severity_glyph(severity) + " " + value;
+ }
+ Label value_label = new Label(shown);
value_label.halign = Align.END;
string? css = severity_css(severity);
if (css != null) {
diff --git a/sensors/tests/contrast-test.vala b/sensors/tests/contrast-test.vala
new file mode 100644
index 0000000..ef3fd21
--- /dev/null
+++ b/sensors/tests/contrast-test.vala
@@ -0,0 +1,100 @@
+using SensorsContrast;
+
+Color[] backgrounds() {
+ return {
+ Color(1.0, 1.0, 1.0),
+ Color(0.0, 0.0, 0.0),
+ Color(0.96, 0.96, 0.96),
+ Color(0.12, 0.12, 0.14),
+ Color(0.30, 0.30, 0.30),
+ Color(0.50, 0.50, 0.50),
+ Color(0.20, 0.35, 0.75),
+ Color(1.0, 0.95, 0.6),
+ composite(Color(0.0, 0.0, 0.0, 0.3), Color(1.0, 1.0, 1.0)),
+ composite(Color(1.0, 1.0, 1.0, 0.4), Color(0.1, 0.1, 0.1))
+ };
+}
+
+double floor_for(Color bg) {
+ return TARGET_MINIMUM;
+}
+
+void test_math() {
+ assert(Math.fabs(relative_luminance(Color(0, 0, 0))) < 1e-9);
+ assert(Math.fabs(relative_luminance(Color(1, 1, 1)) - 1.0) < 1e-9);
+ assert(Math.fabs(contrast_ratio(Color(0, 0, 0), Color(1, 1, 1)) - 21.0) < 1e-6);
+ Color c = composite(Color(1, 0, 0, 0.5), Color(0, 0, 1));
+ assert(Math.fabs(c.r - 0.5) < 1e-9 && Math.fabs(c.b - 0.5) < 1e-9);
+}
+
+void test_neutral() {
+ foreach (Color bg in backgrounds()) {
+ assert(contrast_ratio(neutral_for(bg), bg) >= floor_for(bg));
+ }
+ assert(contrast_ratio(neutral_for(Color(1, 1, 1)), Color(1, 1, 1)) >= TARGET_PREFERRED);
+ assert(contrast_ratio(neutral_for(Color(0, 0, 0)), Color(0, 0, 0)) >= TARGET_PREFERRED);
+}
+
+void test_semantic() {
+ Color[] themes = {
+ Color(0.2, 0.82, 0.48), Color(0.96, 0.83, 0.18), Color(0.88, 0.1, 0.1),
+ Color(0.2, 0.5, 1.0), Color(1.0, 1.0, 1.0), Color(0.0, 0.0, 0.0)
+ };
+ foreach (Color bg in backgrounds()) {
+ foreach (Color t in themes) {
+ Color fixed = best_contrast(t, bg);
+ assert(contrast_ratio(fixed, bg) >= floor_for(bg));
+ }
+ foreach (string role in new string[] { "success", "warning", "error", "accent" }) {
+ bool dark = relative_luminance(bg) < 0.18;
+ assert(contrast_ratio(best_contrast(fallback_for(role, dark), bg), bg) >= floor_for(bg));
+ }
+ }
+}
+
+void test_extremes_reach_preferred() {
+ Color white = Color(1, 1, 1);
+ Color black = Color(0, 0, 0);
+ Color red = Color(0.88, 0.1, 0.1);
+ assert(contrast_ratio(best_contrast(red, white), white) >= TARGET_PREFERRED);
+ assert(contrast_ratio(best_contrast(red, black), black) >= TARGET_PREFERRED);
+}
+
+void test_severity_distinct() {
+ foreach (Color bg in backgrounds()) {
+ if (contrast_ratio(neutral_for(bg), bg) < TARGET_PREFERRED + 2.0) {
+ continue;
+ }
+ bool dark = relative_luminance(bg) < 0.18;
+ Color hot = best_contrast(fallback_for("warning", dark), bg);
+ Color crit = best_contrast(fallback_for("error", dark), bg);
+ Color ok = best_contrast(fallback_for("success", dark), bg);
+ assert(Math.fabs(hot.g - crit.g) > 0.08 || Math.fabs(hot.r - crit.r) > 0.08);
+ assert(Math.fabs(ok.r - crit.r) > 0.08 || Math.fabs(ok.g - crit.g) > 0.08);
+ }
+}
+
+void test_muted() {
+ foreach (Color bg in backgrounds()) {
+ Color n = neutral_for(bg);
+ Color m = muted_for(n, bg);
+ assert(contrast_ratio(m, bg) >= floor_for(bg));
+ }
+}
+
+void test_median() {
+ Color[] s = { Color(0, 0, 0), Color(1, 1, 1), Color(0.2, 0.2, 0.2), Color(0.21, 0.21, 0.21), Color(0.19, 0.19, 0.19) };
+ assert(Math.fabs(median_by_luminance(s).r - 0.2) < 1e-9);
+}
+
+void main(string[] args) {
+ Test.init(ref args);
+ Test.add_func("/sensors/contrast/math", test_math);
+ Test.add_func("/sensors/contrast/neutral", test_neutral);
+ Test.add_func("/sensors/contrast/semantic", test_semantic);
+ Test.add_func("/sensors/contrast/extremes", test_extremes_reach_preferred);
+ Test.add_func("/sensors/contrast/distinct", test_severity_distinct);
+ Test.add_func("/sensors/contrast/muted", test_muted);
+ Test.add_func("/sensors/contrast/median", test_median);
+ Test.run();
+}