From 14452758564d648333ad90adf1f3b321cf531847 Mon Sep 17 00:00:00 2001 From: merefield Date: Wed, 22 Apr 2026 15:57:01 +0100 Subject: [PATCH] FEAT: Improve fullscreen native image rendering --- README.md | 17 ++- lib/termcourse/cli.rb | 4 +- lib/termcourse/ui.rb | 255 +++++++++++++++++++++++++++++++++++++-- test/ui_renderer_test.rb | 144 ++++++++++++++++++++++ 4 files changed, 401 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index d93d9c0..c617c8e 100644 --- a/README.md +++ b/README.md @@ -105,8 +105,8 @@ You can set any of these in your shell or `.env` file. `.env` is auto-loaded if - CLI override: `--lang LANG` applies only to the current run and overrides locale env detection. - `TERMCOURSE_IMAGES`: Set to `0` to disable inline image previews. - `TERMCOURSE_IMAGE_BACKEND`: Choose image backend: `auto` (default), `chafa`, `viu`, or `off`. -- `TERMCOURSE_IMAGE_MODE`: Generic image mode for both `chafa` and `viu`: `stable` (default) or `quality`. -- `TERMCOURSE_IMAGE_COLORS`: Chafa quality color mode: `auto` (default), `none`, `16`, `240`, `256`, `full`. +- `TERMCOURSE_IMAGE_MODE`: Image mode preset for `chafa`/`viu`: `compat` (default), `balanced`, or `high`. +- `TERMCOURSE_IMAGE_COLORS`: Chafa `balanced`/`high` color mode: `auto` (default), `none`, `16`, `240`, `256`, `full`. - `TERMCOURSE_IMAGE_LINES`: Target image preview height in terminal lines (default `14`). - `TERMCOURSE_IMAGE_DEBUG`: Set to `1` to write image debug logs to `/tmp/termcourse_image_debug.txt`. - `TERMCOURSE_IMAGE_QUALITY_FILTER`: Set to `0` to allow low-quality blocky previews (default filters them out). @@ -182,15 +182,20 @@ Color translation: ## Image Guidelines - Image previews are shown only for the expanded post in Topic View. +- Fullscreen image view automatically uses native Sixel rendering when the terminal confirms support. +- If `magick` or `convert` is available, fullscreen native rendering uses ImageMagick to resize the source image to the terminal viewport before emitting SIXEL for a larger, more accurate result. +- If ImageMagick is unavailable, fullscreen native rendering falls back to the existing `chafa` SIXEL path. +- If Sixel is unavailable, fullscreen image view falls back to the text-mode renderer. - Backend selection: - `TERMCOURSE_IMAGE_BACKEND=auto` tries `chafa` first, then `viu`. - Set `TERMCOURSE_IMAGE_BACKEND=chafa` or `TERMCOURSE_IMAGE_BACKEND=viu` to force one backend. - Set `TERMCOURSE_IMAGE_BACKEND=off` or `TERMCOURSE_IMAGES=0` to disable previews. -- Chafa modes: -- `TERMCOURSE_IMAGE_MODE=stable` favors stability and conservative output. -- `TERMCOURSE_IMAGE_MODE=quality` enables higher-detail/color symbol rendering. +- Image modes: +- `TERMCOURSE_IMAGE_MODE=compat` favors stability and conservative output. +- `TERMCOURSE_IMAGE_MODE=balanced` enables higher-detail/color symbol rendering. +- `TERMCOURSE_IMAGE_MODE=high` enables denser symbols and more aggressive chafa tuning for the best text-mode previews. - Color depth: -- `TERMCOURSE_IMAGE_COLORS=auto` detects terminal support (`truecolor`, `256`, etc.) for chafa quality mode. +- `TERMCOURSE_IMAGE_COLORS=auto` detects terminal support (`truecolor`, `256`, etc.) for chafa balanced/high modes. - Set `TERMCOURSE_IMAGE_COLORS=full` to force 24-bit if your terminal supports it. - Height control: - `TERMCOURSE_IMAGE_LINES` controls preview height (line count), default `14`. diff --git a/lib/termcourse/cli.rb b/lib/termcourse/cli.rb index 1480f0a..82c93ca 100644 --- a/lib/termcourse/cli.rb +++ b/lib/termcourse/cli.rb @@ -167,8 +167,8 @@ def help_env_variables ["TERMCOURSE_THEME_FILE", "Theme YAML path. Lookup order: this path, then ./theme.yml, then ~/.config/termcourse/theme.yml."], ["TERMCOURSE_IMAGES", "Set to 0 to disable inline image previews in expanded posts."], ["TERMCOURSE_IMAGE_BACKEND", "Image backend: auto|chafa|viu|off (default: auto)."], - ["TERMCOURSE_IMAGE_MODE", "Image mode for chafa/viu: stable|quality (default: stable)."], - ["TERMCOURSE_IMAGE_COLORS", "Chafa quality color mode: auto|none|16|240|256|full (default: auto)."], + ["TERMCOURSE_IMAGE_MODE", "Image mode preset for chafa/viu: compat|balanced|high (default: compat)."], + ["TERMCOURSE_IMAGE_COLORS", "Chafa balanced/high color mode: auto|none|16|240|256|full (default: auto)."], ["TERMCOURSE_IMAGE_LINES", "Target image preview height in terminal lines (default: 14)."], ["TERMCOURSE_IMAGE_DEBUG", "Set to 1 to write image renderer debug logs to /tmp/termcourse_image_debug.txt."], ["TERMCOURSE_IMAGE_QUALITY_FILTER", "Set to 0 to allow low-quality blocky image previews."], diff --git a/lib/termcourse/ui.rb b/lib/termcourse/ui.rb index 9a077b7..c9dcfc7 100644 --- a/lib/termcourse/ui.rb +++ b/lib/termcourse/ui.rb @@ -9,6 +9,7 @@ require "open3" require "yaml" require "rbconfig" +require "io/console" require "tty-screen" require "tty-cursor" require "tty-box" @@ -167,8 +168,7 @@ def initialize(base_url, api_key: nil, api_username: nil, client: nil, theme_nam @links_enabled = ENV.fetch("TERMCOURSE_LINKS", "1") != "0" @emoji_enabled = ENV.fetch("TERMCOURSE_EMOJI", "1") != "0" @image_backend_preference = ENV.fetch("TERMCOURSE_IMAGE_BACKEND", "auto").to_s.downcase - @image_mode = ENV.fetch("TERMCOURSE_IMAGE_MODE", "stable").to_s.downcase - @image_mode = "stable" unless %w[stable quality].include?(@image_mode) + @image_mode = normalize_image_mode(ENV.fetch("TERMCOURSE_IMAGE_MODE", "compat")) @image_colors_preference = ENV.fetch("TERMCOURSE_IMAGE_COLORS", "auto").to_s.downcase @image_colors = resolve_image_colors @images_enabled = ENV.fetch("TERMCOURSE_IMAGES", "1") != "0" @@ -184,8 +184,10 @@ def initialize(base_url, api_key: nil, api_username: nil, client: nil, theme_nam @resolved_execs = {} @viu_cmd = resolve_executable("viu") @chafa_cmd = resolve_executable("chafa") + @magick_cmd = resolve_executable("magick") || resolve_executable("convert") @renderer = ScreenRenderer.new(pad_line: method(:pad_line)) @image_backend = detect_image_backend + @sixel_supported = nil @image_cache = {} @post_block_cache = {} @post_block_cache_order = [] @@ -912,6 +914,13 @@ def resolve_image_colors detect_terminal_image_colors end + def normalize_image_mode(raw) + mode = raw.to_s.downcase + return mode if %w[compat balanced high].include?(mode) + + "compat" + end + def resolve_color_mode raw = ENV.fetch("TERMCOURSE_COLOR_MODE", "auto").to_s.downcase return raw if %w[truecolor 256 16].include?(raw) @@ -1024,11 +1033,24 @@ def fullscreen_image_loop(url) return if url.nil? with_raw_input_mode do + use_native = sixel_supported_for_fullscreen? + needs_render = true loop do - render_fullscreen_image(url) + if needs_render + if use_native + render_fullscreen_image_native(url) + else + render_fullscreen_image(url) + end + needs_render = false + end + key = read_keypress_with_tick if key == :__tick__ - @resized = false + if @resized + @resized = false + needs_render = true + end next end return if key == "x" || key == "\u001b" @@ -1036,6 +1058,35 @@ def fullscreen_image_loop(url) end end + def render_fullscreen_image_native(url) + width = TTY::Screen.width + height = TTY::Screen.height + image_height = [height - 1, 1].max + payload = render_native_image_url( + url, + width, + image_height, + pixel_viewport: fullscreen_native_pixel_viewport(height) + ) + return render_fullscreen_image(url) if payload.to_s.empty? + + footer = pad_line(theme_text(t("ui.controls.fullscreen_image"), fg: "list_meta"), width) + @renderer.reset! + + output = +"" + output << TTY::Cursor.hide + output << TTY::Cursor.clear_screen + output << TTY::Cursor.move_to(0, 0) + output << payload + output << TTY::Cursor.move_to(0, height - 1) + output << "\e[0m" + output << "\e[2K" + output << footer + output << TTY::Cursor.hide + print output + $stdout.flush + end + def render_fullscreen_image(url) width = TTY::Screen.width height = TTY::Screen.height @@ -1147,7 +1198,7 @@ def render_image_url(url, width, max_lines) image_debug_log("render fallback_sanitized_lines=#{lines.length}") end return [] if lines.empty? - skip_quality_filter = (@image_backend == :viu) || (@image_backend == :chafa && @image_mode == "quality") + skip_quality_filter = (@image_backend == :viu) || (@image_backend == :chafa && @image_mode != "compat") return [] if @image_quality_filter && !skip_quality_filter && low_quality_image_preview?(lines) [format_line("[image]", width)] + lines @@ -1157,6 +1208,32 @@ def render_image_url(url, width, max_lines) [] end + def render_native_image_url(url, width, max_lines, pixel_viewport: nil) + cache_key = [url, width, max_lines, pixel_viewport, :native_sixels] + cached = @image_cache[cache_key] + return cached if cached + + uri = URI.parse(url) + ext = File.extname(uri.path) + payload = nil + Tempfile.create(["termcourse-image", ext]) do |tmp| + download_image_with_limit(url, tmp, @image_max_bytes) + tmp.flush + if pixel_viewport && @magick_cmd + payload = render_native_with_magick_sixels(tmp.path, pixel_viewport) + else + payload = render_native_with_chafa_sixels(tmp.path, width, max_lines) + end + end + + return nil if payload.to_s.empty? + + @image_cache[cache_key] = payload + rescue StandardError + image_debug_log("render native exception") + nil + end + def download_image_with_limit(url, file, max_bytes) body = nil begin @@ -1187,7 +1264,7 @@ def render_with_chafa(path, width, max_lines) def render_with_viu(path, width, max_lines) viu_bin = @viu_cmd || "viu" - shell_prefix = (@image_mode == "quality" && @image_colors == "full") ? "COLORTERM=truecolor " : "" + shell_prefix = (@image_mode != "compat" && @image_colors == "full") ? "COLORTERM=truecolor " : "" argv = viu_argv(viu_bin, path, max_lines) shell_cmd = "#{shell_prefix}#{argv.map { |a| Shellwords.escape(a) }.join(' ')} 2>/dev/null" image_debug_log("viu shell cmd=#{shell_cmd}") @@ -1196,7 +1273,7 @@ def render_with_viu(path, width, max_lines) return shell_lines if shell_lines.any? { |line| !line.strip.empty? } attempts = [{}, { "TERM" => "xterm-256color", "COLORTERM" => "falsecolor" }] - attempts[0]["COLORTERM"] = "truecolor" if @image_mode == "quality" && @image_colors == "full" + attempts[0]["COLORTERM"] = "truecolor" if @image_mode != "compat" && @image_colors == "full" attempts.each do |env| stdout, _status = Open3.capture2e(env, *argv) @@ -1208,16 +1285,62 @@ def render_with_viu(path, width, max_lines) [] end + def render_native_with_chafa_sixels(path, width, max_lines) + Open3.capture2e(*chafa_native_sixel_argv(path, width, max_lines)).first.to_s + end + + def render_native_with_magick_sixels(path, pixel_viewport) + width_px, height_px = pixel_viewport + stdout, status = Open3.capture2e(*magick_sixel_argv(path, width_px, height_px)) + raise "native sixel render failed" unless status.success? + + stdout.to_s + end + def chafa_command(path, width, max_lines) chafa_bin = Shellwords.escape(@chafa_cmd || "chafa") image = Shellwords.escape(path) - if @image_mode == "quality" - "#{chafa_bin} --format symbols --symbols vhalf --colors #{@image_colors} --size #{width}x#{max_lines} #{image} 2>/dev/null" + case @image_mode + when "balanced" + "#{chafa_bin} --format symbols --symbols vhalf --colors #{@image_colors} --optimize 5 --work 5 --size #{width}x#{max_lines} #{image} 2>/dev/null" + when "high" + "#{chafa_bin} --format symbols --symbols vhalf+block --colors #{@image_colors} --optimize 9 --work 9 --size #{width}x#{max_lines} #{image} 2>/dev/null" else - "#{chafa_bin} --format symbols --symbols ascii --colors none --size #{width}x#{max_lines} #{image} 2>/dev/null" + "#{chafa_bin} --format symbols --symbols ascii --colors none --optimize 0 --work 1 --size #{width}x#{max_lines} #{image} 2>/dev/null" end end + def chafa_native_sixel_argv(path, width, max_lines) + [ + @chafa_cmd || "chafa", + "--format", + "sixels", + "--scale", + "max", + "--align", + "top,left", + "--margin-bottom", + "0", + "--optimize", + "9", + "--size", + "#{width}x#{max_lines}", + "--view-size", + "#{width}x#{max_lines}", + path.to_s + ] + end + + def magick_sixel_argv(input_path, width_px, height_px) + [ + @magick_cmd, + input_path.to_s, + "-resize", + "#{width_px}x#{height_px}", + "sixel:-" + ] + end + def viu_argv(viu_bin, path, max_lines) [viu_bin, "-h", max_lines.to_s, "--blocks", "--transparent", path.to_s] end @@ -1241,7 +1364,7 @@ def image_debug_log(message) end def sanitize_rendered_lines(lines, width, backend = nil) - preserve_sgr = backend == :viu || (backend == :chafa && @image_mode == "quality") + preserve_sgr = backend == :viu || (backend == :chafa && @image_mode != "compat") cleaned = lines .map do |line| clean = line.to_s.gsub(/[\r\n]/, "") @@ -1281,6 +1404,116 @@ def strip_controls_except_ansi(text) text.gsub(/[\u0000-\u0008\u000B\u000C\u000E-\u001A\u001C-\u001F\u007F]/, "") end + def sixel_supported_for_fullscreen? + return false unless backend_available?(:chafa) + return @sixel_supported unless @sixel_supported.nil? + + @sixel_supported = detect_sixel_support + end + + def detect_sixel_support + return false unless $stdin.tty? && $stdout.tty? + + input = @reader&.input || $stdin + print "\e[c" + $stdout.flush + + response = +"" + deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 0.15 + while Process.clock_gettime(Process::CLOCK_MONOTONIC) < deadline + readable = IO.select([input], nil, nil, 0.02) + next unless readable + + chunk = input.read_nonblock(256, exception: false) + next if chunk == :wait_readable + break if chunk.nil? + + response << chunk + break if response.include?("c") + end + + parse_da1_sixel_response(response) + rescue StandardError + false + end + + def parse_da1_sixel_response(response) + response.to_s.scan(/\e\[\?([\d;]+)c/).any? do |match| + match.first.to_s.split(";").include?("4") + end + end + + def fullscreen_native_pixel_viewport(screen_height) + area_width, area_height = query_sixel_graphics_pixels + if area_width.nil? || area_height.nil? + area_width, area_height = query_text_area_pixels + end + return nil unless area_width && area_height + + _cell_width, cell_height = query_cell_pixels + footer_height = + if cell_height && cell_height.positive? + cell_height + else + [(area_height.to_f / [screen_height, 1].max).round, 1].max + end + + [area_width, [area_height - footer_height, 1].max] + rescue StandardError + nil + end + + def query_sixel_graphics_pixels + parse_xtsmgraphics_geometry_response(query_terminal("\e[?2;1;0S", final_char: "S"), expected_item: "2") + end + + def query_text_area_pixels + parse_pixel_response(query_terminal("\e[14t", final_char: "t"), expected_code: "4") + end + + def query_cell_pixels + parse_pixel_response(query_terminal("\e[16t", final_char: "t"), expected_code: "6") + end + + def query_terminal(sequence, final_char:, timeout: 0.15) + input = @reader&.input || $stdin + print sequence + $stdout.flush + + response = +"" + deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout + while Process.clock_gettime(Process::CLOCK_MONOTONIC) < deadline + readable = IO.select([input], nil, nil, 0.02) + next unless readable + + chunk = input.read_nonblock(256, exception: false) + next if chunk == :wait_readable + break if chunk.nil? + + response << chunk + break if response.include?(final_char) + end + + response + end + + def parse_pixel_response(response, expected_code:) + match = response.to_s.match(/\e\[(\d+);(\d+);(\d+)t/) + return nil unless match + return nil unless match[1] == expected_code + + [match[3].to_i, match[2].to_i] + end + + def parse_xtsmgraphics_geometry_response(response, expected_item:) + match = response.to_s.match(/\e\[\?(\d+);(\d+);(\d+);(\d+)S/) + return nil unless match + return nil unless match[1] == expected_item + return nil unless match[2] == "0" + + [match[3].to_i, match[4].to_i] + end + def low_quality_image_preview?(lines) text = lines.join return true if text.empty? diff --git a/test/ui_renderer_test.rb b/test/ui_renderer_test.rb index 55c1bbd..98ebfa4 100644 --- a/test/ui_renderer_test.rb +++ b/test/ui_renderer_test.rb @@ -206,6 +206,150 @@ def capture_stdout end end + class UIImageModeTest < Minitest::Test + def setup + @ui = UI.allocate + @ui.instance_variable_set(:@chafa_cmd, "/usr/bin/chafa") + @ui.instance_variable_set(:@image_colors, "full") + end + + def test_normalize_image_mode_defaults_to_compat + assert_equal "compat", @ui.send(:normalize_image_mode, nil) + assert_equal "compat", @ui.send(:normalize_image_mode, "stable") + end + + def test_normalize_image_mode_accepts_balanced_and_high + assert_equal "balanced", @ui.send(:normalize_image_mode, "balanced") + assert_equal "high", @ui.send(:normalize_image_mode, "high") + end + + def test_chafa_command_uses_ascii_mode_for_compat + @ui.instance_variable_set(:@image_mode, "compat") + + command = @ui.send(:chafa_command, "/tmp/example.png", 40, 12) + + assert_includes command, "--format symbols" + assert_includes command, "--symbols ascii" + assert_includes command, "--colors none" + assert_includes command, "--optimize 0" + assert_includes command, "--work 1" + end + + def test_chafa_command_uses_vhalf_mode_for_balanced + @ui.instance_variable_set(:@image_mode, "balanced") + + command = @ui.send(:chafa_command, "/tmp/example.png", 40, 12) + + assert_includes command, "--symbols vhalf" + assert_includes command, "--colors full" + assert_includes command, "--optimize 5" + assert_includes command, "--work 5" + end + + def test_chafa_command_uses_denser_symbols_for_high + @ui.instance_variable_set(:@image_mode, "high") + + command = @ui.send(:chafa_command, "/tmp/example.png", 40, 12) + + assert_includes command, "--symbols vhalf+block" + assert_includes command, "--colors full" + assert_includes command, "--optimize 9" + assert_includes command, "--work 9" + end + + def test_chafa_native_sixel_argv_uses_top_left_fullscreen_viewport + argv = @ui.send(:chafa_native_sixel_argv, "/tmp/example.png", 80, 23) + + assert_includes argv, "--format" + assert_includes argv, "sixels" + assert_includes argv, "--scale" + assert_includes argv, "max" + assert_includes argv, "--align" + assert_includes argv, "top,left" + assert_includes argv, "--margin-bottom" + assert_includes argv, "0" + assert_includes argv, "--view-size" + assert_includes argv, "80x23" + end + end + + class UISixelSupportTest < Minitest::Test + def setup + @ui = UI.allocate + end + + def test_parse_da1_sixel_response_detects_sixel_extension + assert_equal true, @ui.send(:parse_da1_sixel_response, "\e[?62;4;9;22c") + end + + def test_parse_da1_sixel_response_rejects_responses_without_sixel_extension + assert_equal false, @ui.send(:parse_da1_sixel_response, "\e[?1;2;6;9;15;18;22c") + end + + def test_parse_pixel_response_reads_text_area_size + assert_equal [1280, 720], @ui.send(:parse_pixel_response, "\e[4;720;1280t", expected_code: "4") + end + + def test_parse_pixel_response_reads_cell_size + assert_equal [9, 18], @ui.send(:parse_pixel_response, "\e[6;18;9t", expected_code: "6") + end + + def test_parse_xtsmgraphics_geometry_response_reads_sixel_geometry + assert_equal [1920, 1080], @ui.send(:parse_xtsmgraphics_geometry_response, "\e[?2;0;1920;1080S", expected_item: "2") + end + + def test_parse_xtsmgraphics_geometry_response_rejects_unsuccessful_status + assert_nil @ui.send(:parse_xtsmgraphics_geometry_response, "\e[?2;3;1920;1080S", expected_item: "2") + end + + def test_fullscreen_native_pixel_viewport_reserves_footer_row + @ui.define_singleton_method(:query_sixel_graphics_pixels) { [1280, 720] } + @ui.define_singleton_method(:query_cell_pixels) { [9, 18] } + + assert_equal [1280, 702], @ui.send(:fullscreen_native_pixel_viewport, 40) + end + + def test_render_fullscreen_image_native_writes_native_payload_and_footer + ui = UI.allocate + ui.instance_variable_set(:@renderer, UI::ScreenRenderer.new(pad_line: ->(line, width) { line.ljust(width) })) + ui.instance_variable_set(:@locale, "en") + ui.instance_variable_set(:@theme, UI::BUILTIN_THEMES["default"]) + ui.instance_variable_set(:@color_mode, "truecolor") + ui.define_singleton_method(:render_native_image_url) { |_url, _width, _height, pixel_viewport: nil| "SIXELPAYLOAD" } + ui.define_singleton_method(:fullscreen_native_pixel_viewport) { |_screen_height| [1280, 702] } + output = with_stubbed_screen_size(80, 24) do + capture_stdout { ui.send(:render_fullscreen_image_native, "https://example.com/test.png") } + end + + assert_includes output, TTY::Cursor.clear_screen + assert_includes output, "SIXELPAYLOAD" + assert_includes output, "x/esc: back" + end + + private + + def with_stubbed_screen_size(width, height) + original_width = TTY::Screen.method(:width) + original_height = TTY::Screen.method(:height) + TTY::Screen.define_singleton_method(:width) { width } + TTY::Screen.define_singleton_method(:height) { height } + yield + ensure + TTY::Screen.define_singleton_method(:width, original_width) + TTY::Screen.define_singleton_method(:height, original_height) + end + + def capture_stdout + stdout = $stdout + fake = StringIO.new + $stdout = fake + yield + fake.string + ensure + $stdout = stdout + end + end + class UIReadKeypressWithTickTest < Minitest::Test class FakeInput def initialize(waitables)