Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import ai.rever.bossterm.compose.share.isShareViewerIndexResource
import ai.rever.bossterm.compose.share.resyncSentinel
import ai.rever.bossterm.compose.share.webViewerScrollbackLines
import ai.rever.bossterm.compose.share.webTerminalFontFamily
import ai.rever.bossterm.compose.util.ColorUtils
import ai.rever.bossterm.compose.voice.DaemonVoiceToolExecutor
import ai.rever.bossterm.compose.settings.SettingsManager
import ai.rever.bossterm.compose.voice.StampCachedValue
Expand Down Expand Up @@ -1330,6 +1331,10 @@ class DaemonShareServer(
ansi = (0..15).map { hexToCss(palette.getAnsiColorHex(it)) },
fontFamily = webTerminalFontFamily(s.fontName),
fontSize = s.fontSize.toInt(),
minimumContrastRatio = ColorUtils.lightBackgroundGuardRatio(
theme.backgroundColorValue,
s.lightBackgroundMinContrast,
),
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1001,6 +1001,18 @@ object TerminalCanvasRenderer {
?: ctx.settings.defaultBackgroundColor
var fgColor = if (isInverse) baseBg else baseFg
if (isDim) fgColor = ColorUtils.applyDimColor(fgColor)
// Rescue glyphs a truecolor CLI authored for a dark terminal. Measured
// against the background Pass 1 actually painted — for an INVERSE cell
// that is baseFg, so a white-on-white inverse cell is corrected too.
//
// Deliberately AFTER dim: plain white mirrors all the way to #000000,
// while dim white only reaches the floor at ~#727272, so ESC[2m still
// reads as secondary text without applyDimColor needing to change.
fgColor = ColorUtils.legibleOnLightBackground(
fgColor,
if (isInverse) baseFg else baseBg,
ctx.settings.lightBackgroundMinContrast
)

val isBlinkVisible = when {
isSlowBlink -> ctx.slowBlinkVisible
Expand Down Expand Up @@ -1687,6 +1699,12 @@ object TerminalCanvasRenderer {
?: ctx.settings.defaultBackgroundColor
var fgColor = if (isInverse) baseBg else baseFg
if (isDim) fgColor = ColorUtils.applyDimColor(fgColor)
// Same legibility guard as the main text pass; see renderText.
fgColor = ColorUtils.legibleOnLightBackground(
fgColor,
if (isInverse) baseFg else baseBg,
ctx.settings.lightBackgroundMinContrast
)

val textStyle = TextStyle(
color = fgColor,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,23 @@ data class TerminalSettings(
*/
val useAntialiasing: Boolean = true,

/**
* Minimum WCAG contrast ratio for glyphs painted on a **light** cell
* background. `1.0` disables the guard (contrast cannot go below 1:1, so it is
* the natural off sentinel).
*
* Truecolor CLIs hardcode a dark-terminal palette — Claude Code emits a literal
* `ESC[38;2;255;255;255m` for its primary text, which is 1.07:1 against a light
* theme's paper floor and therefore invisible. A 24-bit color bypasses the theme
* and palette layers entirely, so the only place to rescue it is per cell at
* paint time; see [ai.rever.bossterm.compose.util.ColorUtils.legibleOnLightBackground].
*
* Applies only where the painted background is light, so dark themes render
* byte-identically and a cell carrying its own dark background (Claude Code's
* `ESC[48;2;0;0;0m` blocks) keeps its white text.
*/
val lightBackgroundMinContrast: Float = 4.5f,

/**
* Use bundled symbol font (Noto Sans Symbols 2) for symbols like ⏵ ★ ⚡.
* null (default): Platform-specific - macOS uses Apple Color Emoji, Linux uses bundled font.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ data class TerminalSettingsOverride(
val disableLineSpacingInAlternateBuffer: Boolean? = null,
val fillBackgroundInLineSpacing: Boolean? = null,
val useAntialiasing: Boolean? = null,
val lightBackgroundMinContrast: Float? = null,
val preferTerminalFontForSymbols: Boolean? = null,
val defaultForeground: String? = null,
val defaultBackground: String? = null,
Expand Down Expand Up @@ -176,6 +177,7 @@ fun TerminalSettings.withOverrides(override: TerminalSettingsOverride?): Termina
disableLineSpacingInAlternateBuffer = override.disableLineSpacingInAlternateBuffer ?: disableLineSpacingInAlternateBuffer,
fillBackgroundInLineSpacing = override.fillBackgroundInLineSpacing ?: fillBackgroundInLineSpacing,
useAntialiasing = override.useAntialiasing ?: useAntialiasing,
lightBackgroundMinContrast = override.lightBackgroundMinContrast ?: lightBackgroundMinContrast,
preferTerminalFontForSymbols = override.preferTerminalFontForSymbols ?: preferTerminalFontForSymbols,
defaultForeground = override.defaultForeground ?: defaultForeground,
defaultBackground = override.defaultBackground ?: defaultBackground,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,18 @@ fun VisualSettingsSection(
description = "Smooth text rendering"
)

SettingsSlider(
label = "Minimum Text Contrast (Light Backgrounds)",
value = settings.lightBackgroundMinContrast,
onValueChange = { onSettingsChange(settings.copy(lightBackgroundMinContrast = it)) },
onValueChangeFinished = onSettingsSave,
valueRange = 1f..7f,
valueDisplay = { if (it <= 1f) "Off" else "%.1f:1".format(it) },
description = "Darkens text that a CLI hardcoded for a dark terminal so it " +
"stays readable on a light theme. Only affects cells with a light " +
"background; dark themes are untouched"
)

SettingsDropdown(
label = "Symbol Font",
options = listOf("Platform Default", "Bundled (Noto Sans Symbols 2)", "System Default"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import ai.rever.bossterm.compose.settings.theme.ColorPaletteManager
import ai.rever.bossterm.compose.settings.theme.ThemeManager
import ai.rever.bossterm.compose.splits.SplitNode
import ai.rever.bossterm.compose.tabs.TerminalTab
import ai.rever.bossterm.compose.util.ColorUtils
import ai.rever.bossterm.compose.voice.GuiVoiceToolExecutor
import ai.rever.bossterm.compose.voice.VoiceAgentStorage
import ai.rever.bossterm.compose.voice.RemoteVoiceCalls
Expand Down Expand Up @@ -1137,6 +1138,12 @@ class MirrorShare(
ansi = (0..15).map { hexToCss(palette.getAnsiColorHex(it)) },
fontFamily = webTerminalFontFamily(settings.fontName),
fontSize = settings.fontSize.toInt(),
// The in-process guard cannot reach the viewer, so hand xterm.js the floor and
// let it do the per-cell correction on its side.
minimumContrastRatio = ColorUtils.lightBackgroundGuardRatio(
theme.backgroundColorValue,
settings.lightBackgroundMinContrast,
),
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,20 @@ sealed class ServerMessage {
val ansi: List<String>,
val fontFamily: String,
val fontSize: Int,
/**
* Floor for xterm.js's own `minimumContrastRatio`, or `1f` for "off" — the same
* sentinel `TerminalSettings.lightBackgroundMinContrast` uses.
*
* The host decides rather than the viewer because the decision needs the user's
* setting and the theme floor, and only the host has both. Sent as a floor rather
* than pre-corrected colors because the viewer's live text is a raw pty stream
* (see `MirrorShare`'s output tap), so the truecolor a CLI hardcodes never passes
* through anything host-side that could rewrite it.
*
* Defaulted, so an older host that never sends it leaves the viewer's guard off
* instead of failing to decode.
*/
val minimumContrastRatio: Float = 1f,
) : ServerMessage()

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package ai.rever.bossterm.compose.util

import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.graphics.toArgb
import ai.rever.bossterm.terminal.TerminalColor
import ai.rever.bossterm.terminal.emulator.ColorPalette
import ai.rever.bossterm.terminal.emulator.ColorPaletteImpl
Expand Down Expand Up @@ -94,9 +96,14 @@ object ColorUtils {

/**
* Invalidate the indexed color cache. Call when palette/theme changes.
*
* Also drops the legibility-guard caches: a theme change moves the background
* those results were solved against.
*/
fun invalidateColorCache() {
indexedColorCache = null
guardMemo = null
synchronized(guardCacheLock) { guardCache.clear() }
}

/**
Expand Down Expand Up @@ -206,4 +213,214 @@ object ColorUtils {
alpha = color.alpha
)
}

// ===== Legibility guard for light backgrounds =====

/**
* WCAG relative-luminance pivot: above this, black text out-contrasts white
* text on the same background. Solving (1.05)/(L+0.05) = (L+0.05)/0.05 gives
* L = 0.1791.
*
* Used as the "this background is light" test rather than a flat 0.5 so that a
* mid-grey background (#AAAAAA, L = 0.40) still counts as light — white glyphs
* are illegible there too. Every built-in dark theme sits two orders of
* magnitude below it (BOSS Blueprint's #05070B is 0.0028), so they are
* provably untouched by the guard.
*/
private const val LIGHT_BACKGROUND_PIVOT = 0.1791f

/** Steps used when walking a color toward black to reach the contrast floor. */
private const val CLAMP_STEPS = 20

/** Whether [bg] is light enough that dark-authored glyph colors stop being readable on it. */
fun isLightBackground(bg: Color): Boolean = bg.luminance() > LIGHT_BACKGROUND_PIVOT

/**
* The guard's effective floor for a surface whose background is [bg] — [minRatio] on a
* light background, `1f` (off) otherwise.
*
* Exists so the share path can push the same decision to the web viewer without
* restating [LIGHT_BACKGROUND_PIVOT]. The viewer cannot reuse the guard itself: it
* renders through xterm.js, whose glyph pipeline has no per-cell color hook, and its
* live text arrives as a raw pty stream rather than through this process's renderer.
* What it *does* have is xterm.js's own `minimumContrastRatio`, which takes a floor —
* so the floor is the part worth sharing.
*/
fun lightBackgroundGuardRatio(bg: Color, minRatio: Float): Float =
if (minRatio > 1f && isLightBackground(bg)) minRatio else 1f

/**
* WCAG contrast ratio between two colors, ignoring alpha.
*
* The first public copy in the repo; the same math is otherwise duplicated
* privately in `UiTheme.Companion` and two test files.
*/
fun contrastRatio(a: Color, b: Color): Float {
val la = a.luminance() + 0.05f
val lb = b.luminance() + 0.05f
return if (la > lb) la / lb else lb / la
}

/**
* One (fg, bg, minRatio) -> result memo. A single immutable holder behind one
* volatile field rather than three fields, so a concurrent write can never be
* read torn (a half-updated memo would paint the wrong color).
*/
private class GuardMemo(val key: Long, val minRatio: Float, val result: Color)

@Volatile
private var guardMemo: GuardMemo? = null

/**
* LRU behind [guardMemo] for the handful of (fg, bg) pairs a frame actually
* uses. Mirrors [truecolorCache]'s sizing.
*/
private val guardCache = object : LinkedHashMap<Long, Color>(256, 0.75f, true) {
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<Long, Color>?): Boolean {
return size > 512
}
}
private val guardCacheLock = Any()
private var cachedMinRatio: Float = Float.NaN

/**
* Adapt a foreground color that was authored for a dark terminal so it stays
* readable on a light background, leaving everything else exactly as-is.
*
* Truecolor CLIs hardcode their palette: Claude Code emits a literal
* `ESC[38;2;255;255;255m` for primary text, which is 1.07:1 — invisible — on a
* light theme's paper floor. Nothing in the theme or palette layer can reach a
* 24-bit color, so the correction has to happen per cell at paint time.
*
* Returns [fg] untouched unless *all* of these hold:
* - the guard is enabled ([minRatio] > 1),
* - [bg] — the background actually painted for this cell, which for an INVERSE
* cell is the style's foreground — is light,
* - [fg] falls short of [minRatio] against it.
*
* A color that fails is corrected in two steps:
* 1. **Mirror** its HSL lightness about 0.5, preserving hue, saturation and
* alpha. This is what makes the result look *right* rather than merely
* legible: white becomes near-black so primary text reads as ink, #999999
* stays a grey, and a brand orange stays orange.
* 2. **Clamp** toward black until the floor is met. A fully saturated color
* sits near lightness 0.5 already, so the mirror barely moves it (#FFC107
* -> #F8BC00); the clamp is what rescues those.
*
* Never call this for a background color — backgrounds are painted as authored.
*/
fun legibleOnLightBackground(fg: Color, bg: Color, minRatio: Float): Color {
if (minRatio <= 1f) return fg

// The luminance work is deliberately *behind* the cache rather than in front
// of it: a bail-out costs six pow() calls, so on a dark theme — where every
// cell bails — checking first would be a per-cell tax for no benefit. Caching
// the identity result instead makes the disabled case a field read.
val key = (fg.toArgb().toLong() shl 32) or (bg.toArgb().toLong() and 0xFFFFFFFFL)
guardMemo?.let { if (it.key == key && it.minRatio == minRatio) return it.result }

val result = synchronized(guardCacheLock) {
if (cachedMinRatio != minRatio) {
guardCache.clear()
cachedMinRatio = minRatio
}
guardCache.getOrPut(key) { computeLegible(fg, bg, minRatio) }
}
guardMemo = GuardMemo(key, minRatio, result)
return result
}

private fun computeLegible(fg: Color, bg: Color, minRatio: Float): Color {
if (bg.luminance() <= LIGHT_BACKGROUND_PIVOT) return fg
if (contrastRatio(fg, bg) >= minRatio) return fg

// Step 1: mirror lightness, hue and saturation intact.
val hsl = toHsl(fg)
var out = if (hsl[2] > 0.5f) hslToColor(hsl[0], hsl[1], 1f - hsl[2], fg.alpha) else fg

// Step 2: clamp toward black for whatever the mirror could not fix.
if (contrastRatio(out, bg) < minRatio) {
val start = out
out = Color.Black.copy(alpha = fg.alpha)
for (step in 1 until CLAMP_STEPS) {
val candidate = mix(start, Color.Black, step.toFloat() / CLAMP_STEPS)
if (contrastRatio(candidate, bg) >= minRatio) {
out = candidate
break
}
}
}
return out
}

/**
* Component-space (gamma sRGB) mix, preserving [a]'s alpha. Compose's
* [androidx.compose.ui.graphics.lerp] interpolates in Oklab, where small steps
* off pure black round back to black — the same reason `UiTheme` carries its
* own mix.
*/
private fun mix(a: Color, b: Color, t: Float): Color = Color(
red = a.red + (b.red - a.red) * t,
green = a.green + (b.green - a.green) * t,
blue = a.blue + (b.blue - a.blue) * t,
alpha = a.alpha
)

/**
* RGB -> HSL as `[hue, saturation, lightness]`, all 0..1.
*
* Hand-rolled because Compose Multiplatform's desktop `ui-graphics` ships
* `Color.luminance()` but no `Color.hsl()` — that constructor is Android-only.
*/
private fun toHsl(color: Color): FloatArray {
val r = color.red
val g = color.green
val b = color.blue
val max = maxOf(r, g, b)
val min = minOf(r, g, b)
val lightness = (max + min) / 2f
val delta = max - min
if (delta == 0f) return floatArrayOf(0f, 0f, lightness)

val saturation = if (lightness > 0.5f) delta / (2f - max - min) else delta / (max + min)
val hue = when (max) {
r -> (g - b) / delta + if (g < b) 6f else 0f
g -> (b - r) / delta + 2f
else -> (r - g) / delta + 4f
} / 6f
return floatArrayOf(hue, saturation, lightness)
}

/** HSL -> RGB, the inverse of [toHsl]. */
private fun hslToColor(hue: Float, saturation: Float, lightness: Float, alpha: Float): Color {
if (saturation == 0f) {
val v = lightness.coerceIn(0f, 1f)
return Color(v, v, v, alpha)
}
val q = if (lightness < 0.5f) {
lightness * (1f + saturation)
} else {
lightness + saturation - lightness * saturation
}
val p = 2f * lightness - q
return Color(
red = hueToChannel(p, q, hue + 1f / 3f),
green = hueToChannel(p, q, hue),
blue = hueToChannel(p, q, hue - 1f / 3f),
alpha = alpha
)
}

private fun hueToChannel(p: Float, q: Float, rawT: Float): Float {
var t = rawT
if (t < 0f) t += 1f
if (t > 1f) t -= 1f
val channel = when {
t < 1f / 6f -> p + (q - p) * 6f * t
t < 1f / 2f -> q
t < 2f / 3f -> p + (q - p) * (2f / 3f - t) * 6f
else -> p
}
return channel.coerceIn(0f, 1f)
}
}
Loading
Loading