diff --git a/src/config/Config.zig b/src/config/Config.zig index 216e7b685..e59fb2843 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -195,6 +195,19 @@ foreground: Color = .{ .r = 0xFF, .g = 0xFF, .b = 0xFF }, /// the selection color will vary across the selection. @"selection-invert-fg-bg": bool = false, +/// The minimum contrast ratio between the foreground and background +/// colors. The contrast ratio is a value between 1 and 21. A value of +/// 1 allows for no contrast (i.e. black on black). This value is +/// the contrast ratio as defined by the WCAG 2.0 specification. +/// +/// If you want to avoid invisible text (same color as background), +/// a value of 1.1 is a good value. If you want to avoid text that is +/// difficult to read, a value of 3 or higher is a good value. The higher +/// the value, the more likely that text will become black or white. +/// +/// This value does not apply to Emoji or images. +@"minimum-contrast": f64 = 1, + /// Color palette for the 256 color form that many terminal applications /// use. The syntax of this configuration is "N=HEXCODE" where "n" /// is 0 to 255 (for the 256 colors) and HEXCODE is a typical RGB @@ -1569,6 +1582,9 @@ pub fn finalize(self: *Config) !void { // Clamp our split opacity self.@"unfocused-split-opacity" = @min(1.0, @max(0.15, self.@"unfocused-split-opacity")); + // Clamp our contrast + self.@"minimum-contrast" = @min(21, @max(1, self.@"minimum-contrast")); + // Minimmum window size if (self.@"window-width" > 0) self.@"window-width" = @max(10, self.@"window-width"); if (self.@"window-height" > 0) self.@"window-height" = @max(4, self.@"window-height"); diff --git a/src/renderer/Metal.zig b/src/renderer/Metal.zig index 205f7eafc..d230c6d74 100644 --- a/src/renderer/Metal.zig +++ b/src/renderer/Metal.zig @@ -152,6 +152,7 @@ pub const DerivedConfig = struct { selection_background: ?terminal.color.RGB, selection_foreground: ?terminal.color.RGB, invert_selection_fg_bg: bool, + min_contrast: f32, custom_shaders: std.ArrayListUnmanaged([]const u8), custom_shader_animation: bool, links: link.Set, @@ -203,6 +204,7 @@ pub const DerivedConfig = struct { .background = config.background.toTerminalRGB(), .foreground = config.foreground.toTerminalRGB(), .invert_selection_fg_bg = config.@"selection-invert-fg-bg", + .min_contrast = @floatCast(config.@"minimum-contrast"), .selection_background = if (config.@"selection-background") |bg| bg.toTerminalRGB() @@ -374,6 +376,7 @@ pub fn init(alloc: Allocator, options: renderer.Options) !Metal { .cell_size = undefined, .strikethrough_position = @floatFromInt(metrics.strikethrough_position), .strikethrough_thickness = @floatFromInt(metrics.strikethrough_thickness), + .min_contrast = options.config.min_contrast, }, // Fonts @@ -531,6 +534,7 @@ pub fn setFontSize(self: *Metal, size: font.face.DesiredSize) !void { }, .strikethrough_position = @floatFromInt(metrics.strikethrough_position), .strikethrough_thickness = @floatFromInt(metrics.strikethrough_thickness), + .min_contrast = self.uniforms.min_contrast, }; // Recalculate our cell size. If it is the same as before, then we do @@ -1257,6 +1261,9 @@ pub fn changeConfig(self: *Metal, config: *DerivedConfig) !void { self.font_shaper = font_shaper; } + // Set our new minimum contrast + self.uniforms.min_contrast = config.min_contrast; + self.config.deinit(); self.config = config.*; } @@ -1305,6 +1312,7 @@ pub fn setScreenSize( }, .strikethrough_position = old.strikethrough_position, .strikethrough_thickness = old.strikethrough_thickness, + .min_contrast = old.min_contrast, }; // Reset our buffer sizes so that we free memory when the screen shrinks. @@ -1667,7 +1675,7 @@ pub fn updateCell( const alpha: u8 = if (cell.attrs.faint) 175 else 255; // If the cell has a background, we always draw it. - if (colors.bg) |rgb| { + const bg: [4]u8 = if (colors.bg) |rgb| bg: { // Determine our background alpha. If we have transparency configured // then this is dynamic depending on some situations. This is all // in an attempt to make transparency look the best for various @@ -1701,8 +1709,16 @@ pub fn updateCell( .grid_pos = .{ @as(f32, @floatFromInt(x)), @as(f32, @floatFromInt(y)) }, .cell_width = cell.widthLegacy(), .color = .{ rgb.r, rgb.g, rgb.b, bg_alpha }, + .bg_color = .{ 0, 0, 0, 0 }, }); - } + + break :bg .{ rgb.r, rgb.g, rgb.b, bg_alpha }; + } else .{ + self.current_background_color.r, + self.current_background_color.g, + self.current_background_color.b, + @intFromFloat(@max(0, @min(255, @round(self.config.background_opacity * 255)))), + }; // If the cell has a character, draw it if (cell.char > 0) { @@ -1729,6 +1745,7 @@ pub fn updateCell( .grid_pos = .{ @as(f32, @floatFromInt(x)), @as(f32, @floatFromInt(y)) }, .cell_width = cell.widthLegacy(), .color = .{ colors.fg.r, colors.fg.g, colors.fg.b, alpha }, + .bg_color = bg, .glyph_pos = .{ glyph.atlas_x, glyph.atlas_y }, .glyph_size = .{ glyph.width, glyph.height }, .glyph_offset = .{ glyph.offset_x, glyph.offset_y }, @@ -1759,6 +1776,7 @@ pub fn updateCell( .grid_pos = .{ @as(f32, @floatFromInt(x)), @as(f32, @floatFromInt(y)) }, .cell_width = cell.widthLegacy(), .color = .{ color.r, color.g, color.b, alpha }, + .bg_color = bg, .glyph_pos = .{ glyph.atlas_x, glyph.atlas_y }, .glyph_size = .{ glyph.width, glyph.height }, .glyph_offset = .{ glyph.offset_x, glyph.offset_y }, @@ -1771,6 +1789,7 @@ pub fn updateCell( .grid_pos = .{ @as(f32, @floatFromInt(x)), @as(f32, @floatFromInt(y)) }, .cell_width = cell.widthLegacy(), .color = .{ colors.fg.r, colors.fg.g, colors.fg.b, alpha }, + .bg_color = bg, }); } @@ -1834,6 +1853,7 @@ fn addCursor( }, .cell_width = if (wide) 2 else 1, .color = .{ color.r, color.g, color.b, alpha }, + .bg_color = .{ 0, 0, 0, 0 }, .glyph_pos = .{ glyph.atlas_x, glyph.atlas_y }, .glyph_size = .{ glyph.width, glyph.height }, .glyph_offset = .{ glyph.offset_x, glyph.offset_y }, @@ -1886,6 +1906,7 @@ fn addPreeditCell( .grid_pos = .{ @as(f32, @floatFromInt(x)), @as(f32, @floatFromInt(y)) }, .cell_width = if (cp.wide) 2 else 1, .color = .{ bg.r, bg.g, bg.b, 255 }, + .bg_color = .{ bg.r, bg.g, bg.b, 255 }, }); // Add our text @@ -1894,6 +1915,7 @@ fn addPreeditCell( .grid_pos = .{ @as(f32, @floatFromInt(x)), @as(f32, @floatFromInt(y)) }, .cell_width = if (cp.wide) 2 else 1, .color = .{ fg.r, fg.g, fg.b, 255 }, + .bg_color = .{ bg.r, bg.g, bg.b, 255 }, .glyph_pos = .{ glyph.atlas_x, glyph.atlas_y }, .glyph_size = .{ glyph.width, glyph.height }, .glyph_offset = .{ glyph.offset_x, glyph.offset_y }, diff --git a/src/renderer/OpenGL.zig b/src/renderer/OpenGL.zig index eda31218e..8e144b832 100644 --- a/src/renderer/OpenGL.zig +++ b/src/renderer/OpenGL.zig @@ -101,6 +101,7 @@ surface_mailbox: apprt.surface.Mailbox, /// simple we apply all OpenGL context changes in the render() call. deferred_screen_size: ?SetScreenSize = null, deferred_font_size: ?SetFontSize = null, +deferred_config: ?SetConfig = null, /// If we're drawing with single threaded operations draw_mutex: DrawMutex = drawMutexZero, @@ -206,6 +207,20 @@ const SetFontSize = struct { } }; +const SetConfig = struct { + fn apply(self: SetConfig, r: *const OpenGL) !void { + _ = self; + const gl_state = r.gl_state orelse return error.OpenGLUninitialized; + + const bind = try gl_state.cell_program.program.use(); + defer bind.unbind(); + try gl_state.cell_program.program.setUniform( + "min_contrast", + r.config.min_contrast, + ); + } +}; + /// The configuration for this renderer that is derived from the main /// configuration. This must be exported so that we don't need to /// pass around Config pointers which makes memory management a pain. @@ -224,6 +239,7 @@ pub const DerivedConfig = struct { selection_background: ?terminal.color.RGB, selection_foreground: ?terminal.color.RGB, invert_selection_fg_bg: bool, + min_contrast: f32, custom_shaders: std.ArrayListUnmanaged([]const u8), custom_shader_animation: bool, links: link.Set, @@ -275,6 +291,7 @@ pub const DerivedConfig = struct { .background = config.background.toTerminalRGB(), .foreground = config.foreground.toTerminalRGB(), .invert_selection_fg_bg = config.@"selection-invert-fg-bg", + .min_contrast = @floatCast(config.@"minimum-contrast"), .selection_background = if (config.@"selection-background") |bg| bg.toTerminalRGB() @@ -336,6 +353,7 @@ pub fn init(alloc: Allocator, options: renderer.Options) !OpenGL { .padding = options.padding, .surface_mailbox = options.surface_mailbox, .deferred_font_size = .{ .metrics = metrics }, + .deferred_config = .{}, }; } @@ -462,6 +480,7 @@ pub fn displayRealize(self: *OpenGL) !void { self.deferred_screen_size = .{ .size = size }; } self.deferred_font_size = .{ .metrics = metrics }; + self.deferred_config = .{}; } /// Callback called by renderer.Thread when it begins. @@ -1148,6 +1167,10 @@ fn addPreeditCell( .g = bg.g, .b = bg.b, .a = 255, + .bg_r = 0, + .bg_g = 0, + .bg_b = 0, + .bg_a = 0, }); // Add our text @@ -1166,6 +1189,10 @@ fn addPreeditCell( .g = fg.g, .b = fg.b, .a = 255, + .bg_r = bg.r, + .bg_g = bg.g, + .bg_b = bg.b, + .bg_a = 255, }); } @@ -1227,6 +1254,10 @@ fn addCursor( .g = color.g, .b = color.b, .a = alpha, + .bg_r = 0, + .bg_g = 0, + .bg_b = 0, + .bg_a = 0, .glyph_x = glyph.atlas_x, .glyph_y = glyph.atlas_y, .glyph_width = glyph.width, @@ -1334,7 +1365,7 @@ pub fn updateCell( const alpha: u8 = if (cell.attrs.faint) 175 else 255; // If the cell has a background, we always draw it. - if (colors.bg) |rgb| { + const bg: [4]u8 = if (colors.bg) |rgb| bg: { // Determine our background alpha. If we have transparency configured // then this is dynamic depending on some situations. This is all // in an attempt to make transparency look the best for various @@ -1378,8 +1409,19 @@ pub fn updateCell( .g = rgb.g, .b = rgb.b, .a = bg_alpha, + .bg_r = 0, + .bg_g = 0, + .bg_b = 0, + .bg_a = 0, }); - } + + break :bg .{ rgb.r, rgb.g, rgb.b, bg_alpha }; + } else .{ + self.draw_background.r, + self.draw_background.g, + self.draw_background.b, + @intFromFloat(@max(0, @min(255, @round(self.config.background_opacity * 255)))), + }; // If the cell has a character, draw it if (cell.char > 0) { @@ -1416,6 +1458,10 @@ pub fn updateCell( .g = colors.fg.g, .b = colors.fg.b, .a = alpha, + .bg_r = bg[0], + .bg_g = bg[1], + .bg_b = bg[2], + .bg_a = bg[3], }); } @@ -1453,6 +1499,10 @@ pub fn updateCell( .g = color.g, .b = color.b, .a = alpha, + .bg_r = bg[0], + .bg_g = bg[1], + .bg_b = bg[2], + .bg_a = bg[3], }); } @@ -1472,6 +1522,10 @@ pub fn updateCell( .g = colors.fg.g, .b = colors.fg.b, .a = alpha, + .bg_r = bg[0], + .bg_g = bg[1], + .bg_b = bg[2], + .bg_a = bg[3], }); } @@ -1511,6 +1565,9 @@ pub fn changeConfig(self: *OpenGL, config: *DerivedConfig) !void { self.font_shaper = font_shaper; } + // Update our uniforms + self.deferred_config = .{}; + self.config.deinit(); self.config = config.*; } @@ -1728,6 +1785,10 @@ fn drawCellProgram( try v.apply(self); self.deferred_font_size = null; } + if (self.deferred_config) |v| { + try v.apply(self); + self.deferred_config = null; + } // Draw background images first try self.drawImages( diff --git a/src/renderer/metal/shaders.zig b/src/renderer/metal/shaders.zig index 030ae2b6c..bd938802d 100644 --- a/src/renderer/metal/shaders.zig +++ b/src/renderer/metal/shaders.zig @@ -95,6 +95,7 @@ pub const Cell = extern struct { glyph_size: [2]u32 = .{ 0, 0 }, glyph_offset: [2]i32 = .{ 0, 0 }, color: [4]u8, + bg_color: [4]u8, cell_width: u8, pub const Mode = enum(u8) { @@ -125,6 +126,10 @@ pub const Uniforms = extern struct { /// Metrics for underline/strikethrough strikethrough_position: f32, strikethrough_thickness: f32, + + /// The minimum contrast ratio for text. The contrast ratio is calculated + /// according to the WCAG 2.0 spec. + min_contrast: f32, }; /// The uniforms used for custom postprocess shaders. @@ -401,6 +406,17 @@ fn initCellPipeline(device: objc.Object, library: objc.Object) !objc.Object { attr.setProperty("offset", @as(c_ulong, @offsetOf(Cell, "color"))); attr.setProperty("bufferIndex", @as(c_ulong, 0)); } + { + const attr = attrs.msgSend( + objc.Object, + objc.sel("objectAtIndexedSubscript:"), + .{@as(c_ulong, 7)}, + ); + + attr.setProperty("format", @intFromEnum(mtl.MTLVertexFormat.uchar4)); + attr.setProperty("offset", @as(c_ulong, @offsetOf(Cell, "bg_color"))); + attr.setProperty("bufferIndex", @as(c_ulong, 0)); + } { const attr = attrs.msgSend( objc.Object, diff --git a/src/renderer/opengl/CellProgram.zig b/src/renderer/opengl/CellProgram.zig index d1ea969fe..83bbbab72 100644 --- a/src/renderer/opengl/CellProgram.zig +++ b/src/renderer/opengl/CellProgram.zig @@ -29,12 +29,18 @@ pub const Cell = extern struct { glyph_offset_x: i32 = 0, glyph_offset_y: i32 = 0, - /// vec4 fg_color_in + /// vec4 color_in r: u8, g: u8, b: u8, a: u8, + /// vec4 bg_color_in + bg_r: u8, + bg_g: u8, + bg_b: u8, + bg_a: u8, + /// uint mode mode: CellMode, @@ -105,9 +111,11 @@ pub fn init() !CellProgram { offset += 2 * @sizeOf(i32); try vbobind.attributeAdvanced(4, 4, gl.c.GL_UNSIGNED_BYTE, false, @sizeOf(Cell), offset); offset += 4 * @sizeOf(u8); - try vbobind.attributeIAdvanced(5, 1, gl.c.GL_UNSIGNED_BYTE, @sizeOf(Cell), offset); - offset += 1 * @sizeOf(u8); + try vbobind.attributeAdvanced(5, 4, gl.c.GL_UNSIGNED_BYTE, false, @sizeOf(Cell), offset); + offset += 4 * @sizeOf(u8); try vbobind.attributeIAdvanced(6, 1, gl.c.GL_UNSIGNED_BYTE, @sizeOf(Cell), offset); + offset += 1 * @sizeOf(u8); + try vbobind.attributeIAdvanced(7, 1, gl.c.GL_UNSIGNED_BYTE, @sizeOf(Cell), offset); try vbobind.enableAttribArray(0); try vbobind.enableAttribArray(1); try vbobind.enableAttribArray(2); @@ -115,6 +123,7 @@ pub fn init() !CellProgram { try vbobind.enableAttribArray(4); try vbobind.enableAttribArray(5); try vbobind.enableAttribArray(6); + try vbobind.enableAttribArray(7); try vbobind.attributeDivisor(0, 1); try vbobind.attributeDivisor(1, 1); try vbobind.attributeDivisor(2, 1); @@ -122,6 +131,7 @@ pub fn init() !CellProgram { try vbobind.attributeDivisor(4, 1); try vbobind.attributeDivisor(5, 1); try vbobind.attributeDivisor(6, 1); + try vbobind.attributeDivisor(7, 1); return .{ .program = program, diff --git a/src/renderer/shaders/cell.metal b/src/renderer/shaders/cell.metal index e6ba3f7ac..9d1731095 100644 --- a/src/renderer/shaders/cell.metal +++ b/src/renderer/shaders/cell.metal @@ -13,6 +13,7 @@ struct Uniforms { float2 cell_size; float strikethrough_position; float strikethrough_thickness; + float min_contrast; }; struct VertexIn { @@ -29,7 +30,12 @@ struct VertexIn { // the text color. For styles, this is the color of the style. uchar4 color [[ attribute(5) ]]; - // The fields below are present only when rendering text. + // The fields below are present only when rendering text (fg mode) + + // The background color of the cell. This is used to determine if + // we need to render the text with a different color to ensure + // contrast. + uchar4 bg_color [[ attribute(7) ]]; // The position of the glyph in the texture (x,y) uint2 glyph_pos [[ attribute(2) ]]; @@ -49,6 +55,55 @@ struct VertexOut { float2 tex_coord; }; +//------------------------------------------------------------------- +// Color Functions +//------------------------------------------------------------------- +#pragma mark - Colors + +// https://www.w3.org/TR/2008/REC-WCAG20-20081211/#relativeluminancedef +float luminance_component(float c) { + if (c <= 0.03928f) { + return c / 12.92f; + } else { + return pow((c + 0.055f) / 1.055f, 2.4f); + } +} + +float relative_luminance(float3 color) { + color.r = luminance_component(color.r); + color.g = luminance_component(color.g); + color.b = luminance_component(color.b); + float3 weights = float3(0.2126f, 0.7152f, 0.0722f); + return dot(color, weights); +} + +// https://www.w3.org/TR/2008/REC-WCAG20-20081211/#contrast-ratiodef +float contrast_ratio(float3 color1, float3 color2) { + float l1 = relative_luminance(color1); + float l2 = relative_luminance(color2); + return (max(l1, l2) + 0.05f) / (min(l1, l2) + 0.05f); +} + +// Return the fg if the contrast ratio is greater than min, otherwise +// return a color that satisfies the contrast ratio. Currently, the color +// is always white or black, whichever has the highest contrast ratio. +float4 contrasted_color(float min, float4 fg, float4 bg) { + float3 fg_premult = fg.rgb * fg.a; + float3 bg_premult = bg.rgb * bg.a; + float ratio = contrast_ratio(fg_premult, bg_premult); + if (ratio < min) { + float white_ratio = contrast_ratio(float3(1.0f), bg_premult); + float black_ratio = contrast_ratio(float3(0.0f), bg_premult); + if (white_ratio > black_ratio) { + return float4(1.0f); + } else { + return float4(0.0f, 0.0f, 0.0f, 1.0f); + } + } + + return fg; +} + //------------------------------------------------------------------- // Terminal Grid Cell Shader //------------------------------------------------------------------- @@ -112,6 +167,15 @@ vertex VertexOut uber_vertex( // Calculate the texture coordinate in pixels. This is NOT normalized // (between 0.0 and 1.0) and must be done in the fragment shader. out.tex_coord = float2(input.glyph_pos) + float2(input.glyph_size) * position; + + // If we have a minimum contrast, we need to check if we need to + // change the color of the text to ensure it has enough contrast + // with the background. + if (uniforms.min_contrast > 1.0f && input.mode == MODE_FG) { + float4 bg_color = float4(input.bg_color) / 255.0f; + out.color = contrasted_color(uniforms.min_contrast, out.color, bg_color); + } + break; } diff --git a/src/renderer/shaders/cell.v.glsl b/src/renderer/shaders/cell.v.glsl index ccca33982..bf35469db 100644 --- a/src/renderer/shaders/cell.v.glsl +++ b/src/renderer/shaders/cell.v.glsl @@ -25,14 +25,18 @@ layout (location = 3) in vec2 glyph_offset; // depends on mode. layout (location = 4) in vec4 color_in; +// Only set for MODE_FG, this is the background color of the FG text. +// This is used to detect minimal contrast for the text. +layout (location = 5) in vec4 bg_color_in; + // The mode of this shader. The mode determines what fields are used, // what the output will be, etc. This shader is capable of executing in // multiple "modes" so that we can share some logic and so that we can draw // the entire terminal grid in a single GPU pass. -layout (location = 5) in uint mode_in; +layout (location = 6) in uint mode_in; // The width in cells of this item. -layout (location = 6) in uint grid_width; +layout (location = 7) in uint grid_width; // The background or foreground color for the fragment, depending on // whether this is a background or foreground pass. @@ -54,6 +58,7 @@ uniform vec2 cell_size; uniform mat4 projection; uniform float strikethrough_position; uniform float strikethrough_thickness; +uniform float min_contrast; /******************************************************************** * Modes @@ -75,6 +80,61 @@ uniform float strikethrough_thickness; * */ +//------------------------------------------------------------------- +// Color Functions +//------------------------------------------------------------------- + +// https://www.w3.org/TR/2008/REC-WCAG20-20081211/#relativeluminancedef +float luminance_component(float c) { + if (c <= 0.03928) { + return c / 12.92; + } else { + return pow((c + 0.055) / 1.055, 2.4); + } +} + +float relative_luminance(vec3 color) { + vec3 color_adjusted = vec3( + luminance_component(color.r), + luminance_component(color.g), + luminance_component(color.b) + ); + + vec3 weights = vec3(0.2126, 0.7152, 0.0722); + return dot(color_adjusted, weights); +} + +// https://www.w3.org/TR/2008/REC-WCAG20-20081211/#contrast-ratiodef +float contrast_ratio(vec3 color1, vec3 color2) { + float luminance1 = relative_luminance(color1) + 0.05; + float luminance2 = relative_luminance(color2) + 0.05; + return max(luminance1, luminance2) / min(luminance1, luminance2); +} + +// Return the fg if the contrast ratio is greater than min, otherwise +// return a color that satisfies the contrast ratio. Currently, the color +// is always white or black, whichever has the highest contrast ratio. +vec4 contrasted_color(float min_ratio, vec4 fg, vec4 bg) { + vec3 fg_premult = fg.rgb * fg.a; + vec3 bg_premult = bg.rgb * bg.a; + float ratio = contrast_ratio(fg_premult, bg_premult); + if (ratio < min_ratio) { + float white_ratio = contrast_ratio(vec3(1.0, 1.0, 1.0), bg_premult); + float black_ratio = contrast_ratio(vec3(0.0, 0.0, 0.0), bg_premult); + if (white_ratio > black_ratio) { + return vec4(1.0, 1.0, 1.0, fg.a); + } else { + return vec4(0.0, 0.0, 0.0, fg.a); + } + } + + return fg; +} + +//------------------------------------------------------------------- +// Main +//------------------------------------------------------------------- + void main() { // We always forward our mode unmasked because the fragment // shader doesn't use any of the masks. @@ -147,8 +207,15 @@ void main() { vec2 glyph_tex_size = glyph_size / text_size; glyph_tex_coords = glyph_tex_pos + glyph_tex_size * position; - // Set our foreground color output - color = color_in / 255.; + // If we have a minimum contrast, we need to check if we need to + // change the color of the text to ensure it has enough contrast + // with the background. + vec4 color_final = color_in / 255.0; + if (min_contrast > 1.0 && mode == MODE_FG) { + vec4 bg_color = bg_color_in / 255.0; + color_final = contrasted_color(min_contrast, color_final, bg_color); + } + color = color_final; break; case MODE_STRIKETHROUGH: