From 658d7c69add4421058c6b64c84b6f21ca7ea0e87 Mon Sep 17 00:00:00 2001 From: Shugo Maeda Date: Sun, 22 Mar 2026 17:50:56 -0700 Subject: [PATCH 1/8] Add default_colors API to Theme DSL for setting terminal fg/bg Themes can now declare default foreground/background colors via t.default_colors(foreground:, background:) which calls Curses.assume_default_colors during activation. Themes without default_colors reset to terminal defaults (-1, -1). Co-Authored-By: Claude Opus 4.6 --- lib/textbringer/theme.rb | 32 +++++++++++++++++ lib/textbringer/themes/catppuccin.rb | 2 ++ lib/textbringer/themes/github.rb | 2 ++ lib/textbringer/themes/gruvbox.rb | 2 ++ lib/textbringer/themes/sonokai.rb | 2 ++ test/textbringer/test_theme.rb | 54 ++++++++++++++++++++++++++++ 6 files changed, 94 insertions(+) diff --git a/lib/textbringer/theme.rb b/lib/textbringer/theme.rb index dcadbfd7..e8683a9a 100644 --- a/lib/textbringer/theme.rb +++ b/lib/textbringer/theme.rb @@ -73,6 +73,7 @@ def initialize(name) @name = name @palettes = {} @face_definitions = [] + @default_colors = nil end attr_reader :name @@ -87,6 +88,10 @@ def face(name, **attrs) @face_definitions << [name, attrs] end + def default_colors(foreground:, background:) + @default_colors = { foreground: foreground, background: background } + end + def activate mode = self.class.background_mode tier = self.class.color_tier @@ -113,6 +118,33 @@ def activate Face.define(face_name, **resolved) end @@current = self + apply_default_colors(palette, tier) + end + + private + + def apply_default_colors(palette, tier) + if @default_colors + fg = resolve_default_color(@default_colors[:foreground], palette, tier) + bg = resolve_default_color(@default_colors[:background], palette, tier) + else + fg = "default" + bg = "default" + end + Curses.assume_default_colors(Color[fg], Color[bg]) + end + + def resolve_default_color(val, palette, tier) + if val.is_a?(Symbol) + color = palette.resolve(val, tier) + unless color + raise EditorError, + "Unknown palette color :#{val} for default_colors" + end + color + else + val || "default" + end end private_class_method def self.detect_background_via_osc11 diff --git a/lib/textbringer/themes/catppuccin.rb b/lib/textbringer/themes/catppuccin.rb index b7167fa1..a7a54c08 100644 --- a/lib/textbringer/themes/catppuccin.rb +++ b/lib/textbringer/themes/catppuccin.rb @@ -68,6 +68,8 @@ p.color :rosewater, hex: "#d78787", ansi: "red" end + t.default_colors foreground: :text, background: :base + # Programming faces (from catppuccin/nvim syntax.lua) t.face :comment, foreground: :overlay2 t.face :preprocessing_directive, foreground: :pink diff --git a/lib/textbringer/themes/github.rb b/lib/textbringer/themes/github.rb index a3c481c0..cd6db30e 100644 --- a/lib/textbringer/themes/github.rb +++ b/lib/textbringer/themes/github.rb @@ -48,6 +48,8 @@ p.color :darkblue, hex: "#c1daec", ansi: "blue" # dcolors.darkblue = blue1 end + t.default_colors foreground: :fg, background: :bg + # Programming faces t.face :comment, foreground: :comment # Comment = base2 t.face :preprocessing_directive, foreground: :red # PreProc = red diff --git a/lib/textbringer/themes/gruvbox.rb b/lib/textbringer/themes/gruvbox.rb index 86b3285a..12f9c7f6 100644 --- a/lib/textbringer/themes/gruvbox.rb +++ b/lib/textbringer/themes/gruvbox.rb @@ -47,6 +47,8 @@ p.color :orange, hex: "#d75f00", ansi: "red" end + t.default_colors foreground: :fg1, background: :bg0 + # Programming faces t.face :comment, foreground: :gray t.face :preprocessing_directive, foreground: :aqua diff --git a/lib/textbringer/themes/sonokai.rb b/lib/textbringer/themes/sonokai.rb index 0940556d..7a383581 100644 --- a/lib/textbringer/themes/sonokai.rb +++ b/lib/textbringer/themes/sonokai.rb @@ -26,6 +26,8 @@ p.color :purple, hex: "#d787d7", ansi: "magenta" end + t.default_colors foreground: :fg, background: :bg0 + # Programming faces (from sonokai highlight groups) t.face :comment, foreground: :grey t.face :preprocessing_directive, foreground: :red diff --git a/test/textbringer/test_theme.rb b/test/textbringer/test_theme.rb index 6f38eed3..8f7f0bf4 100644 --- a/test/textbringer/test_theme.rb +++ b/test/textbringer/test_theme.rb @@ -106,6 +106,60 @@ def test_activate_with_inherit Face.delete(:derived_kw) end + def test_activate_sets_default_colors + Theme.define "test_defcol" do |t| + t.palette :dark do |p| + p.color :fg, hex: "#ffffff", ansi: "white" + p.color :bg, hex: "#000000", ansi: "black" + end + t.default_colors foreground: :fg, background: :bg + t.face :test_defcol_face, foreground: :fg + end + Theme["test_defcol"].activate + fg_num = Color["#ffffff"] + bg_num = Color["#000000"] + assert_equal([fg_num, bg_num], Curses.default_colors) + ensure + Face.delete(:test_defcol_face) + end + + def test_activate_resets_default_colors_when_not_specified + Theme.define "test_with_defcol" do |t| + t.palette :dark do |p| + p.color :fg, hex: "#ffffff", ansi: "white" + p.color :bg, hex: "#000000", ansi: "black" + end + t.default_colors foreground: :fg, background: :bg + t.face :test_wdc_face, foreground: :fg + end + Theme["test_with_defcol"].activate + assert_not_equal([-1, -1], Curses.default_colors) + + Theme.define "test_no_defcol" do |t| + t.palette :dark do |p| + p.color :fg, hex: "#ffffff", ansi: "white" + end + t.face :test_ndc_face, foreground: :fg + end + Theme["test_no_defcol"].activate + assert_equal([-1, -1], Curses.default_colors) + ensure + Face.delete(:test_wdc_face) + Face.delete(:test_ndc_face) + end + + def test_activate_default_colors_raises_on_unknown_palette + Theme.define "test_bad_defcol" do |t| + t.palette :dark do |p| + p.color :fg, hex: "#ffffff", ansi: "white" + end + t.default_colors foreground: :nonexistent, background: :fg + end + assert_raise(EditorError) do + Theme["test_bad_defcol"].activate + end + end + def test_load_default_activates_faces face = Face[:comment] assert_not_nil(face) From a998b8efa6c9a346885d41c10d021a3953078273 Mon Sep 17 00:00:00 2001 From: Shugo Maeda Date: Sun, 22 Mar 2026 17:57:25 -0700 Subject: [PATCH 2/8] Add default_colors to tokyonight and molokai themes Both original themes set Normal fg/bg; our ports were missing it. Co-Authored-By: Claude Opus 4.6 --- lib/textbringer/themes/molokai.rb | 2 ++ lib/textbringer/themes/tokyonight.rb | 2 ++ 2 files changed, 4 insertions(+) diff --git a/lib/textbringer/themes/molokai.rb b/lib/textbringer/themes/molokai.rb index 7b760668..a3c5cbcf 100644 --- a/lib/textbringer/themes/molokai.rb +++ b/lib/textbringer/themes/molokai.rb @@ -27,6 +27,8 @@ p.color :search, hex: "#ffd787", ansi: "yellow" # 222 Search bg end + t.default_colors foreground: :fg, background: :bg + # Programming faces t.face :comment, foreground: :comment t.face :preprocessing_directive, foreground: :green diff --git a/lib/textbringer/themes/tokyonight.rb b/lib/textbringer/themes/tokyonight.rb index abb6ee24..26bbc5ce 100644 --- a/lib/textbringer/themes/tokyonight.rb +++ b/lib/textbringer/themes/tokyonight.rb @@ -29,6 +29,8 @@ p.color :blue5, hex: "#89ddff", ansi: "cyan" # Operator, punctuation delimiters end + t.default_colors foreground: :fg, background: :bg + # Programming faces t.face :comment, foreground: :comment t.face :preprocessing_directive, foreground: :cyan # PreProc = c.cyan From b39ed6f2f216f0f8ad5f050cac1e435eae56fbe0 Mon Sep 17 00:00:00 2001 From: Shugo Maeda Date: Sun, 22 Mar 2026 18:07:11 -0700 Subject: [PATCH 3/8] Add set_foreground_color and set_background_color commands Emacs-compatible interactive commands for changing the terminal's default foreground or background color independently. Window now tracks current default colors so each command can update one without resetting the other. Co-Authored-By: Claude Opus 4.6 --- lib/textbringer/commands/windows.rb | 14 ++++++++++++++ lib/textbringer/window.rb | 6 +++++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/lib/textbringer/commands/windows.rb b/lib/textbringer/commands/windows.rb index 575d341b..bc53bbe6 100644 --- a/lib/textbringer/commands/windows.rb +++ b/lib/textbringer/commands/windows.rb @@ -84,6 +84,20 @@ module Commands end end + define_command(:set_foreground_color, doc: <<~EOD) do + Set the default foreground color. + EOD + |color = read_from_minibuffer("Foreground color: ")| + Window.set_default_colors(color, nil) + end + + define_command(:set_background_color, doc: <<~EOD) do + Set the default background color. + EOD + |color = read_from_minibuffer("Background color: ")| + Window.set_default_colors(nil, color) + end + define_command(:list_buffers, doc: <<~EOD) do |buffers = Buffer.list| List the existing buffers. EOD diff --git a/lib/textbringer/window.rb b/lib/textbringer/window.rb index 1cdae58a..6307692e 100644 --- a/lib/textbringer/window.rb +++ b/lib/textbringer/window.rb @@ -33,6 +33,8 @@ class Window @@minibuffer_selected = nil @@echo_area = nil @@has_colors = false + @@default_fg = "default" + @@default_bg = "default" def self.list(include_echo_area: false) if include_echo_area @@ -133,7 +135,9 @@ def self.colors end def self.set_default_colors(fg, bg) - Curses.assume_default_colors(Color[fg], Color[bg]) + @@default_fg = fg if fg + @@default_bg = bg if bg + Curses.assume_default_colors(Color[@@default_fg], Color[@@default_bg]) Window.redraw end From 1e104510eb23da876ff3994f3b12eb374226f611 Mon Sep 17 00:00:00 2001 From: Shugo Maeda Date: Sun, 22 Mar 2026 18:32:07 -0700 Subject: [PATCH 4/8] Use :default face instead of color_pair 0 for base text rendering Curses.assume_default_colors only affects color_pair 0 and does not reliably update the display when called after initial setup (e.g. to keep terminal background transparency while using theme fg). Now set_default_colors also defines a :default face with the resolved fg/bg colors. All fallback sites in Window that previously used attr_set(0, 0) now use Face[:default], ensuring theme colors are applied consistently even when one component is overridden. Co-Authored-By: Claude Opus 4.6 --- lib/textbringer/window.rb | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/lib/textbringer/window.rb b/lib/textbringer/window.rb index 6307692e..0c8e9a30 100644 --- a/lib/textbringer/window.rb +++ b/lib/textbringer/window.rb @@ -138,7 +138,9 @@ def self.set_default_colors(fg, bg) @@default_fg = fg if fg @@default_bg = bg if bg Curses.assume_default_colors(Color[@@default_fg], Color[@@default_bg]) - Window.redraw + Face.define(:default, + foreground: @@default_fg, background: @@default_bg) + Window.redraw if @@started end def self.load_faces @@ -712,7 +714,8 @@ def redisplay_mode_line @mode_line.addstr(" #{line},#{column}") @mode_line.addstr(" (#{@buffer.mode_names.join(' ')})") @mode_line.addstr(" " * (columns - @mode_line.curx)) - @mode_line.attr_set(0, 0) + default = Face[:default] + @mode_line.attr_set(default&.text_attrs || 0, default&.color_pair || 0) @mode_line.noutrefresh end @@ -952,6 +955,7 @@ def get_char end def apply_face_attrs(win, face) + face ||= Face[:default] win.attr_set(face&.text_attrs || 0, face&.color_pair || 0) end @@ -971,7 +975,8 @@ def apply_window_attrs(win) end win.attr_set(text_attrs, face.color_pair) else - win.attr_set(0, 0) + default = Face[:default] + win.attr_set(default&.text_attrs || 0, default&.color_pair || 0) end end end @@ -1020,7 +1025,8 @@ def redisplay @window.addstr(@buffer.input_method_status) end @window.setpos(0, 0) - @window.attr_set(0, 0) + default = Face[:default] + @window.attr_set(default&.text_attrs || 0, default&.color_pair || 0) @in_region = false @in_isearch = false @current_hl_face = nil From 969ac5d215701b76d88d0f18f66809fc6a9804b7 Mon Sep 17 00:00:00 2001 From: Shugo Maeda Date: Sun, 22 Mar 2026 18:38:34 -0700 Subject: [PATCH 5/8] Validate before caching in set_default_colors, handle empty input - Resolve colors via Color[] before updating @@default_fg/@@default_bg so invalid input does not corrupt the cache - Treat empty strings as nil (no-op) to handle blank minibuffer input - Add tests for partial update (nil preserves other side) and empty string handling Co-Authored-By: Claude Opus 4.6 --- lib/textbringer/window.rb | 15 ++++++++++----- test/textbringer/test_window.rb | 22 ++++++++++++++++++++++ 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/lib/textbringer/window.rb b/lib/textbringer/window.rb index 0c8e9a30..227b5920 100644 --- a/lib/textbringer/window.rb +++ b/lib/textbringer/window.rb @@ -135,11 +135,16 @@ def self.colors end def self.set_default_colors(fg, bg) - @@default_fg = fg if fg - @@default_bg = bg if bg - Curses.assume_default_colors(Color[@@default_fg], Color[@@default_bg]) - Face.define(:default, - foreground: @@default_fg, background: @@default_bg) + fg = nil if fg.respond_to?(:empty?) && fg.empty? + bg = nil if bg.respond_to?(:empty?) && bg.empty? + new_fg = fg || @@default_fg + new_bg = bg || @@default_bg + fg_color = Color[new_fg] + bg_color = Color[new_bg] + Curses.assume_default_colors(fg_color, bg_color) + @@default_fg = new_fg + @@default_bg = new_bg + Face.define(:default, foreground: new_fg, background: new_bg) Window.redraw if @@started end diff --git a/test/textbringer/test_window.rb b/test/textbringer/test_window.rb index d0c482ad..cb5b4e8b 100644 --- a/test/textbringer/test_window.rb +++ b/test/textbringer/test_window.rb @@ -432,6 +432,28 @@ def test_s_set_default_colors assert_equal([-1, -1], Curses.default_colors) end + def test_s_set_default_colors_partial_update + Window.set_default_colors("red", "blue") + assert_equal([1, 4], Curses.default_colors) + + # Update only bg, fg should be preserved + Window.set_default_colors(nil, "default") + assert_equal([1, -1], Curses.default_colors) + + # Update only fg, bg should be preserved + Window.set_default_colors("green", nil) + assert_equal([2, -1], Curses.default_colors) + end + + def test_s_set_default_colors_empty_string_ignored + Window.set_default_colors("red", "blue") + assert_equal([1, 4], Curses.default_colors) + + # Empty string should be treated as nil (no change) + Window.set_default_colors("", "") + assert_equal([1, 4], Curses.default_colors) + end + private def window_string(window) From 9f479d55b36bcf54ec75744713bd0a8252e18927 Mon Sep 17 00:00:00 2001 From: Shugo Maeda Date: Mon, 23 Mar 2026 10:44:22 +0900 Subject: [PATCH 6/8] Window.set_default_colors should be used to update @@default_fg/@@default_bg --- lib/textbringer/theme.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/textbringer/theme.rb b/lib/textbringer/theme.rb index e8683a9a..f633d750 100644 --- a/lib/textbringer/theme.rb +++ b/lib/textbringer/theme.rb @@ -131,7 +131,7 @@ def apply_default_colors(palette, tier) fg = "default" bg = "default" end - Curses.assume_default_colors(Color[fg], Color[bg]) + Window.set_default_colors(fg, bg) end def resolve_default_color(val, palette, tier) From 4d159ab7a76e24bd38cc0ea3dfb39f4c6be2eeab Mon Sep 17 00:00:00 2001 From: Shugo Maeda Date: Mon, 23 Mar 2026 10:44:56 +0900 Subject: [PATCH 7/8] Empty string should be treated as error --- lib/textbringer/window.rb | 6 +----- test/textbringer/test_window.rb | 7 ++++--- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/lib/textbringer/window.rb b/lib/textbringer/window.rb index 227b5920..35558668 100644 --- a/lib/textbringer/window.rb +++ b/lib/textbringer/window.rb @@ -135,13 +135,9 @@ def self.colors end def self.set_default_colors(fg, bg) - fg = nil if fg.respond_to?(:empty?) && fg.empty? - bg = nil if bg.respond_to?(:empty?) && bg.empty? new_fg = fg || @@default_fg new_bg = bg || @@default_bg - fg_color = Color[new_fg] - bg_color = Color[new_bg] - Curses.assume_default_colors(fg_color, bg_color) + Curses.assume_default_colors(Color[new_fg], Color[new_bg]) @@default_fg = new_fg @@default_bg = new_bg Face.define(:default, foreground: new_fg, background: new_bg) diff --git a/test/textbringer/test_window.rb b/test/textbringer/test_window.rb index cb5b4e8b..188baa33 100644 --- a/test/textbringer/test_window.rb +++ b/test/textbringer/test_window.rb @@ -449,9 +449,10 @@ def test_s_set_default_colors_empty_string_ignored Window.set_default_colors("red", "blue") assert_equal([1, 4], Curses.default_colors) - # Empty string should be treated as nil (no change) - Window.set_default_colors("", "") - assert_equal([1, 4], Curses.default_colors) + # Empty string should be treated as error + assert_raise(EditorError) do + Window.set_default_colors("", "") + end end private From 6207487c15de5e7674ec84b7d32372b04590ab80 Mon Sep 17 00:00:00 2001 From: Shugo Maeda Date: Sun, 22 Mar 2026 19:01:28 -0700 Subject: [PATCH 8/8] Fix Windows test failure by using Color[] instead of hardcoded values PDCurses (Windows) assigns different numbers to color constants than ncurses (e.g. COLOR_RED=4 vs 1). Use Color["red"] etc. to resolve platform-appropriate values in assertions. Co-Authored-By: Claude Opus 4.6 --- test/textbringer/test_window.rb | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/test/textbringer/test_window.rb b/test/textbringer/test_window.rb index 188baa33..b99f36dd 100644 --- a/test/textbringer/test_window.rb +++ b/test/textbringer/test_window.rb @@ -433,21 +433,26 @@ def test_s_set_default_colors end def test_s_set_default_colors_partial_update + red = Color["red"] + blue = Color["blue"] + green = Color["green"] Window.set_default_colors("red", "blue") - assert_equal([1, 4], Curses.default_colors) + assert_equal([red, blue], Curses.default_colors) # Update only bg, fg should be preserved Window.set_default_colors(nil, "default") - assert_equal([1, -1], Curses.default_colors) + assert_equal([red, -1], Curses.default_colors) # Update only fg, bg should be preserved Window.set_default_colors("green", nil) - assert_equal([2, -1], Curses.default_colors) + assert_equal([green, -1], Curses.default_colors) end def test_s_set_default_colors_empty_string_ignored + red = Color["red"] + blue = Color["blue"] Window.set_default_colors("red", "blue") - assert_equal([1, 4], Curses.default_colors) + assert_equal([red, blue], Curses.default_colors) # Empty string should be treated as error assert_raise(EditorError) do