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
128 changes: 106 additions & 22 deletions lib/termcourse/ui.rb
Original file line number Diff line number Diff line change
Expand Up @@ -461,7 +461,7 @@ def render_topic_list(topics, selected, filter:, top_period:, loading: false)
themed_pm_topic_list_compact_line(line_index + 1, title, topic, width: width)
else
replies = topic_reply_count(topic)
themed_topic_list_line(line_index + 1, title, replies, width: width)
themed_topic_list_line(line_index + 1, title, replies, width: width, topic: topic)
end
else
themed_topic_list_row(topic, line_index + 1, width, list_mode, filter)
Expand Down Expand Up @@ -2340,33 +2340,36 @@ def trap_resize
nil
end

def themed_topic_list_line(number, title, replies, width: nil)
def themed_topic_list_line(number, title, replies, width: nil, topic: nil)
num_raw = format("%3d", number)
meta_raw = "(#{replies} replies)"
safe_title = compact_title_for_width(title, num_raw, meta_raw, width)
badge_text = compact_topic_state_badge_text(topic, num_raw, meta_raw, width)
safe_title = compact_title_for_width(title, num_raw, meta_raw, width, badge_text: badge_text)
num = theme_text(num_raw, fg: "list_numbers")
title_text = theme_text(safe_title, fg: "list_text")
title_text = themed_title_with_badge(safe_title, badge_text)
meta = theme_text(meta_raw, fg: "list_meta")
"#{num} #{title_text} #{meta}"
end

def themed_pm_topic_list_compact_line(number, title, topic, width: nil)
num_raw = format("%3d", number)
meta_raw = "(#{pm_users_label(topic)})"
safe_title = compact_title_for_width(title, num_raw, meta_raw, width)
badge_text = compact_topic_state_badge_text(topic, num_raw, meta_raw, width)
safe_title = compact_title_for_width(title, num_raw, meta_raw, width, badge_text: badge_text)
num = theme_text(num_raw, fg: "list_numbers")
title_text = theme_text(safe_title, fg: "list_text")
title_text = themed_title_with_badge(safe_title, badge_text)
users_text = theme_text(meta_raw, fg: "list_meta")
"#{num} #{title_text} #{users_text}"
end

def compact_title_for_width(title, num_raw, meta_raw, width)
def compact_title_for_width(title, num_raw, meta_raw, width, badge_text: nil)
text = normalize_inline_text(title.to_s)
return text if width.nil? || width <= 0

# Row format: "<num><2 spaces><title><2 spaces><meta>"
title_width = width - display_width(num_raw) - display_width(meta_raw) - 4
title_width = [title_width, 3].max
badge_width = badge_text.to_s.empty? ? 0 : display_width(badge_text) + 1
title_width = width - display_width(num_raw) - display_width(meta_raw) - 4 - badge_width
title_width = [title_width, 0].max
truncate_display(text, title_width)
end

Expand All @@ -2389,16 +2392,21 @@ def themed_topic_list_header(width, mode, filter)

def themed_topic_list_row(topic, number, width, mode, filter)
spec = topic_list_table_spec(width, mode, filter)
row = build_topic_list_table_row(
spec[:columns].map do |col|
{
text: topic_list_cell_value(topic, number, col[:key], filter),
width: spec[:widths][col[:key]],
align: col[:align]
}
separator = theme_text(" ", fg: "list_text")
spec[:columns].map do |col|
text = topic_list_cell_value(topic, number, col[:key], filter)
width = spec[:widths][col[:key]].to_i
align = col[:align] || :left

if col[:key] == :title
badge_text = topic_state_badge_text(topic)
reserve = badge_text.to_s.empty? ? 0 : display_width(badge_text) + 1
title_text = truncate_display(normalize_inline_text(text.to_s), [width - reserve, 0].max)
fit_topic_list_cell(themed_title_with_badge(title_text, badge_text), width, align: align)
else
theme_text(fit_topic_list_cell(text, width, align: align), fg: "list_text")
end
)
theme_text(row, fg: "list_text")
end.join(separator)
end

def topic_list_table_spec(width, mode, filter)
Expand Down Expand Up @@ -2468,7 +2476,7 @@ def topic_list_pm_widths(width, mode)

def build_topic_list_table_row(columns)
columns.map do |col|
text = normalize_inline_text(col[:text].to_s)
text = col[:text].to_s
fit_topic_list_cell(text, col[:width].to_i, align: col[:align] || :left)
end.join(" ")
end
Expand All @@ -2484,9 +2492,85 @@ def normalize_inline_text(text)
def fit_topic_list_cell(text, width, align: :left)
return "" if width <= 0

clipped = truncate_display(text.to_s, width)
gap = [width - display_width(clipped), 0].max
align == :right ? (" " * gap) + clipped : clipped + (" " * gap)
normalized = normalize_inline_text(text.to_s)
visible = strip_all_ansi(normalized)
if display_width(visible) > width
if visible == normalized
normalized = truncate_display(visible, width)
visible = normalized
else
normalized = clamp_visible_with_ansi(normalized, width)
visible = strip_all_ansi(normalized)
end
end
gap = [width - display_width(visible), 0].max
align == :right ? (" " * gap) + normalized : normalized + (" " * gap)
end

def clamp_visible_with_ansi(text, max_width)
return "" if max_width <= 0

width = 0
out = +""
idx = 0
saw_ansi = false

while idx < text.bytesize
if (token = ansi_token_at(text, idx))
out << token
idx += token.bytesize
saw_ansi = true
next
end

ch = text.byteslice(idx..).each_char.first
break unless ch

ch_width = display_width(ch)
break if width + ch_width > max_width

out << ch
width += ch_width
idx += ch.bytesize
end

out << "\e[0m" if saw_ansi && !out.end_with?("\e[0m")
out
end

def ansi_token_at(text, idx)
rest = text.byteslice(idx..)
rest[/\A\e\[[0-9;]*m/] ||
rest[/\A\e\]8;;.*?\a/] ||
rest[/\A\e\]8;;\a/]
end

def compact_topic_state_badge_text(topic, num_raw, meta_raw, width)
badge_text = topic_state_badge_text(topic)
return badge_text if badge_text.empty? || width.nil? || width <= 0

title_width = width - display_width(num_raw) - display_width(meta_raw) - 4
reserve = display_width(badge_text) + 1
return "" if title_width <= reserve

badge_text
end

def themed_title_with_badge(title, badge_text)
title_text = theme_text(title, fg: "list_text")
return title_text if badge_text.to_s.empty?

"#{title_text} #{theme_text(badge_text, fg: 'accent')}"
end

def topic_state_badge_text(topic)
return "" unless topic.is_a?(Hash)

unread_count = topic["unread_posts"].to_i
unread_count = topic["new_posts"].to_i if unread_count <= 0
return "[#{unread_count}]" if unread_count.positive?

topic["unseen"] == true ? "•" : ""
end

def truncate_display(text, width)
Expand Down
88 changes: 88 additions & 0 deletions test/ui_renderer_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,39 @@ def test_themed_topic_list_line_ellipsizes_to_fit_width
assert_includes visible, "..."
end

def test_themed_topic_list_line_appends_unread_badge
topic = { "unread_posts" => 3 }

line = @ui.send(
:themed_topic_list_line,
1,
"A topic with unread posts",
42,
width: 48,
topic: topic
)

visible = @ui.send(:strip_all_ansi, line)
assert_includes visible, "[3]"
assert_operator @ui.send(:display_width, visible), :<=, 48
end

def test_themed_topic_list_line_with_badge_still_fits_requested_width
topic = { "unread_posts" => 3 }

line = @ui.send(
:themed_topic_list_line,
1,
"A topic with unread posts",
42,
width: 24,
topic: topic
)

visible = @ui.send(:strip_all_ansi, line)
assert_operator @ui.send(:display_width, visible), :<=, 24
end

def test_themed_pm_topic_list_compact_line_ellipsizes_to_fit_width
topic = {
"title" => "ignored",
Expand Down Expand Up @@ -301,6 +334,61 @@ def test_topic_list_mode_uses_exact_thresholds
assert_equal :category, @ui.send(:topic_list_mode, UI::TOPIC_LIST_WIDE_STATS_MIN - 1)
assert_equal :stats, @ui.send(:topic_list_mode, UI::TOPIC_LIST_WIDE_STATS_MIN)
end

def test_themed_topic_list_row_appends_unseen_badge_to_title_cell
topic = {
"title" => "Fresh topic",
"unseen" => true,
"category_id" => 2,
"reply_count" => 0
}
@ui.define_singleton_method(:site_categories) { { 2 => "Staff" } }

line = @ui.send(:themed_topic_list_row, topic, 1, 130, :category, :latest)
visible = @ui.send(:strip_all_ansi, line)

assert_includes visible, "Fresh topic •"
end

def test_themed_topic_list_row_keeps_following_columns_themed_after_badge
topic = {
"title" => "Fresh topic",
"unseen" => true,
"category_id" => 2,
"reply_count" => 0
}
@ui.define_singleton_method(:site_categories) { { 2 => "Staff" } }

line = @ui.send(:themed_topic_list_row, topic, 1, 130, :category, :latest)
list_text_ansi = @ui.send(:ansi_fg, @ui.send(:theme_color, "list_text"))

assert_includes line, "#{list_text_ansi}Staff"
assert_match(/#{Regexp.escape(list_text_ansi)}\s+0/, line)
end

def test_themed_topic_list_row_themes_inter_column_separators
topic = {
"title" => "Fresh topic",
"unseen" => true,
"category_id" => 2,
"reply_count" => 0
}
@ui.define_singleton_method(:site_categories) { { 2 => "Staff" } }

line = @ui.send(:themed_topic_list_row, topic, 1, 130, :category, :latest)
list_text_ansi = @ui.send(:ansi_fg, @ui.send(:theme_color, "list_text"))

assert_includes line, "#{list_text_ansi} "
end

def test_fit_topic_list_cell_preserves_ansi_when_clipping_styled_text
styled = "#{@ui.send(:theme_text, '•', fg: 'accent')}#{@ui.send(:theme_text, 'abcdef', fg: 'list_text')}"
fitted = @ui.send(:fit_topic_list_cell, styled, 3, align: :left)
accent_ansi = @ui.send(:ansi_fg, @ui.send(:theme_color, "accent"))

assert_operator @ui.send(:display_width, @ui.send(:strip_all_ansi, fitted)), :<=, 3
assert_includes fitted, accent_ansi
end
end

class UISearchFormattingTest < Minitest::Test
Expand Down
Loading