heathrow 0.7.1 → 0.7.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9c40dfa3681941910b0e8ac358021003220ee9e88b9861b1c20b735bb0444623
4
- data.tar.gz: f2acadd2ea11c58fa522db1abf94ebdb31a20fd0fc346eac94aefac38a9fe847
3
+ metadata.gz: 123f2c7c6134d4ea1add0517f649b94353918d1e76583a9f714a1e49d7879f96
4
+ data.tar.gz: 26e26497ec050fc246cb2ee1eff2389ceb3088dccb8c83d09257425dd152888a
5
5
  SHA512:
6
- metadata.gz: '084222a43672e22966f1716b62a59d7d5961a5148c8a47f858c08cb3a33d863fb1933c74649792ae4a8945fb0a36bbbb6fcbfbe46bdb1e9ed045874708530f95'
7
- data.tar.gz: 31b2b552ea905f4cdb463f347cc7855d7f6d7b0b9125e4ba903927b20002e9a87b38754a1b2cedf9c519eb37a9692e805dd0ef66b9c25fc85958e0994f214e81
6
+ metadata.gz: 2130ee038cbc2d9bc8ee4b7855809afaa1c171aba9b8b0967a85d71c9397b39b2ef86afcef58b3301d15556dfc3d07ab8d91d8d12ed77d4a90879904562829a5
7
+ data.tar.gz: 3557ffc9cf7af99441c6c4062e63d7bc9c18145b1f537d10607d420f059221f36c33d1f1a86cad76036a69d003eedb39f26c471013c2dba839187350a10c6aff
@@ -170,9 +170,17 @@ module Heathrow
170
170
  changed_base_ids.each do |base_id|
171
171
  flags = self.class.parse_maildir_flags(disk_files[base_id])
172
172
  db_row = db_index[base_id]
173
- if flags[:seen] != (db_row[:read] == 1) || flags[:flagged] != (db_row[:starred] == 1) || flags[:replied] != (db_row[:replied] == 1)
173
+ # For replied: DB is authoritative. If DB says replied but disk
174
+ # doesn't have R flag, add it to disk rather than clearing DB.
175
+ disk_replied = flags[:replied]
176
+ db_replied = db_row[:replied] == 1
177
+ if db_replied && !disk_replied
178
+ rename_with_flag(disk_files[base_id], 'R', add: true)
179
+ disk_replied = true
180
+ end
181
+ if flags[:seen] != (db_row[:read] == 1) || flags[:flagged] != (db_row[:starred] == 1) || disk_replied != db_replied
174
182
  db.execute("UPDATE messages SET read = ?, starred = ?, replied = ? WHERE id = ?",
175
- flags[:seen] ? 1 : 0, flags[:flagged] ? 1 : 0, flags[:replied] ? 1 : 0, db_row[:id])
183
+ flags[:seen] ? 1 : 0, flags[:flagged] ? 1 : 0, disk_replied ? 1 : 0, db_row[:id])
176
184
  end
177
185
  # Update external_id and metadata if filename changed
178
186
  current_filename = File.basename(disk_files[base_id])
@@ -725,6 +725,8 @@ module Heathrow
725
725
  page_down
726
726
  when 'PgUP'
727
727
  page_up
728
+ when 'L'
729
+ load_more_messages
728
730
  when 'w'
729
731
  change_width
730
732
  when 'Y'
@@ -1498,28 +1500,32 @@ module Heathrow
1498
1500
  available_width -= 2 if @panes[:left].border # Extra space for border chars
1499
1501
  available_width -= 3 # Space for N flag + replied flag + indicator column (tag/star/attachment/D)
1500
1502
 
1501
- # Truncate sender to fit
1503
+ # Truncate sender to fit (use display_width for CJK characters)
1502
1504
  sender_max = 15
1503
- sender_display = sender.length > sender_max ? sender[0..sender_max-2] + '…' : sender.ljust(sender_max)
1504
-
1505
+ dw = Rcurses.display_width(sender)
1506
+ sender_display = if dw > sender_max
1507
+ truncate_to_width(sender, sender_max - 1) + '…'
1508
+ else
1509
+ sender + ' ' * [sender_max - dw, 0].max
1510
+ end
1511
+
1505
1512
  # Build the line with timestamp, icon and sender
1506
1513
  icon = respond_to?(:get_source_icon) ? get_source_icon(msg['source_type']) : '•'
1507
1514
  line_prefix = "#{timestamp} #{icon} #{sender_display} "
1508
-
1509
- # Calculate remaining space for subject
1510
- subject_width = available_width - line_prefix.length - 1 # -1 for safety
1511
- if subject_width > 0 && subject.length > subject_width
1512
- subject = subject[0..subject_width-2] + '…'
1515
+
1516
+ # Calculate remaining space for subject (use display_width for CJK)
1517
+ prefix_dw = Rcurses.display_width(line_prefix)
1518
+ subject_width = available_width - prefix_dw - 1 # -1 for safety
1519
+ subject_dw = Rcurses.display_width(subject)
1520
+ if subject_width > 0 && subject_dw > subject_width
1521
+ subject = truncate_to_width(subject, subject_width - 1) + '…'
1522
+ subject_dw = Rcurses.display_width(subject)
1513
1523
  end
1514
-
1515
- # Format the complete line and pad to full width
1516
- line = line_prefix + subject
1517
- # Pad line to full available width so background color spans entire width
1518
- line = line.ljust(available_width)
1519
-
1520
- prefix_part = "#{timestamp} #{icon} #{sender_display} "
1524
+
1525
+ prefix_part = line_prefix
1521
1526
  subject_part = subject.strip
1522
- padding = " " * [available_width - prefix_part.length - subject_part.length, 0].max
1527
+ total_dw = Rcurses.display_width(prefix_part) + Rcurses.display_width(subject_part)
1528
+ padding = " " * [available_width - total_dw, 0].max
1523
1529
  finalize_line(msg, selected, prefix_part, subject_part, source_color, padding)
1524
1530
  end
1525
1531
 
@@ -1885,7 +1891,7 @@ module Heathrow
1885
1891
  # Show view name instantly
1886
1892
  render_top_bar
1887
1893
 
1888
- @load_limit = 1000
1894
+ @load_limit = 200
1889
1895
  @filtered_messages = @db.get_messages({}, @load_limit, 0, light: true)
1890
1896
  sort_messages
1891
1897
  @index = 0
@@ -1911,7 +1917,11 @@ module Heathrow
1911
1917
  clear_inline_image
1912
1918
  end
1913
1919
 
1914
- return unless current_msg
1920
+ unless current_msg
1921
+ @panes[:right].text = ""
1922
+ @panes[:right].refresh
1923
+ return
1924
+ end
1915
1925
 
1916
1926
  msg = current_msg
1917
1927
 
@@ -1979,28 +1989,28 @@ module Heathrow
1979
1989
  end
1980
1990
  else
1981
1991
  # Regular message display
1982
- header << "From: #{msg['sender']}".fg(39) if msg['sender']
1992
+ header << "From: #{msg['sender']}".fg(2) if msg['sender']
1983
1993
  # Show recipients (To field)
1984
1994
  to = msg['recipients'] || msg['recipient']
1985
1995
  if to
1986
1996
  to_list = to.is_a?(String) ? (JSON.parse(to) rescue [to]) : to
1987
1997
  to_str = to_list.is_a?(Array) ? to_list.join(', ') : to_list.to_s
1988
- header << "To: #{to_str}".fg(45) unless to_str.empty?
1998
+ header << "To: #{to_str}".fg(2) unless to_str.empty?
1989
1999
  end
1990
2000
  # Show CC recipients
1991
2001
  cc = msg['cc']
1992
2002
  if cc
1993
2003
  cc_list = cc.is_a?(String) ? (JSON.parse(cc) rescue [cc]) : cc
1994
2004
  cc_str = cc_list.is_a?(Array) ? cc_list.join(', ') : cc_list.to_s
1995
- header << "Cc: #{cc_str}".fg(45) unless cc_str.empty?
2005
+ header << "Cc: #{cc_str}".fg(2) unless cc_str.empty?
1996
2006
  end
1997
2007
  # For weechat, show channel name from metadata instead of content preview
1998
2008
  meta = msg['metadata']
1999
2009
  meta = JSON.parse(meta) if meta.is_a?(String) rescue nil
2000
2010
  if meta.is_a?(Hash) && meta['channel_name']
2001
- header << "Subject: #{meta['channel_name']}".b.fg(226)
2011
+ header << "Subject: #{meta['channel_name']}".b.fg(1)
2002
2012
  elsif msg['subject']
2003
- header << "Subject: #{msg['subject']}".b.fg(226)
2013
+ header << "Subject: #{msg['subject']}".b.fg(1)
2004
2014
  end
2005
2015
  end
2006
2016
 
@@ -2314,18 +2324,22 @@ module Heathrow
2314
2324
 
2315
2325
  def check_load_more
2316
2326
  return unless @load_limit && @filtered_messages
2317
- n = @filtered_messages.size
2318
- return if n < @load_limit # Haven't hit the limit yet
2319
- threshold = (@load_limit * 0.95).to_i
2320
- if @index >= threshold
2321
- load_more_messages
2322
- end
2327
+ return if @filtered_messages.size < @load_limit # Haven't hit the limit yet
2328
+ display_size = message_count
2329
+ return if display_size == 0
2330
+ return unless @index >= display_size - 10
2331
+ return if @last_autoload_index == @index
2332
+ @last_autoload_index = @index
2333
+ load_more_messages
2323
2334
  end
2324
2335
 
2325
2336
  def load_more_messages
2326
2337
  return unless @load_limit
2338
+ # Remember current message so we can restore position after reload
2339
+ cur = current_message
2340
+ cur_id = cur['id'] if cur
2327
2341
  old_count = @filtered_messages.size
2328
- @load_limit += 1000
2342
+ @load_limit += 200
2329
2343
 
2330
2344
  if @current_folder
2331
2345
  light_cols = "id, source_id, external_id, thread_id, parent_id, sender, sender_name, recipients, subject, substr(content, 1, 200) as content, timestamp, received_at, read AS is_read, starred AS is_starred, archived, labels, metadata, attachments, folder, replied"
@@ -2345,7 +2359,21 @@ module Heathrow
2345
2359
 
2346
2360
  sort_messages
2347
2361
  new_count = @filtered_messages.size
2348
- set_feedback("Loaded #{new_count} messages (+#{new_count - old_count})", 156, 2) if new_count > old_count
2362
+ return if new_count <= old_count
2363
+
2364
+ # In threaded mode, set pending restore so the threaded rebuild finds our position
2365
+ if @show_threaded && cur_id
2366
+ @pending_restore_id = cur_id
2367
+ end
2368
+
2369
+ # Force threaded view to rebuild organizer with new messages
2370
+ if @show_threaded && respond_to?(:organize_current_messages)
2371
+ organize_current_messages(true)
2372
+ end
2373
+
2374
+ set_feedback("Loaded #{new_count} messages (+#{new_count - old_count})", 156, 2)
2375
+ render_message_list
2376
+ render_top_bar
2349
2377
  end
2350
2378
 
2351
2379
  # Ensure all feeds/channels from a source have at least some messages loaded
@@ -2512,7 +2540,7 @@ module Heathrow
2512
2540
 
2513
2541
  render_top_bar
2514
2542
 
2515
- @load_limit = 1000
2543
+ @load_limit = 200
2516
2544
  @filtered_messages = @db.get_messages({is_read: false}, @load_limit, 0, light: true)
2517
2545
  sort_messages
2518
2546
  @index = 0
@@ -2559,7 +2587,7 @@ module Heathrow
2559
2587
  @section_order = view[:filters]['section_order'].dup
2560
2588
  end
2561
2589
 
2562
- @load_limit = 1000
2590
+ @load_limit = 200
2563
2591
  if view && view[:filters] && !view[:filters].empty?
2564
2592
  apply_view_filters(view)
2565
2593
  sort_messages
@@ -3701,6 +3729,7 @@ module Heathrow
3701
3729
 
3702
3730
  def show_folder_browser
3703
3731
  @in_folder_browser = true
3732
+ @in_favorites_browser = false
3704
3733
  @folder_browser_index = 0
3705
3734
  @panes[:top].bg = @topcolor
3706
3735
  @folder_collapsed ||= {}
@@ -3781,8 +3810,10 @@ module Heathrow
3781
3810
  @panes[:right].refresh
3782
3811
  end
3783
3812
 
3784
- # Update top bar
3785
- @panes[:top].text = " Heathrow - ".b.fg(255) + "Folder Browser".b.fg(201) + " [#{@folder_display.size} folders]".fg(246)
3813
+ # Update top bar (preserve Favorites title if in favorites mode)
3814
+ browser_title = @in_favorites_browser ? "Favorites" : "Folder Browser"
3815
+ browser_color = @in_favorites_browser ? 226 : 201
3816
+ @panes[:top].text = " Heathrow - ".b.fg(255) + browser_title.b.fg(browser_color) + " [#{@folder_display.size} folders]".fg(246)
3786
3817
  @panes[:top].refresh
3787
3818
 
3788
3819
  # Update bottom bar
@@ -3902,6 +3933,7 @@ module Heathrow
3902
3933
  render_folder_browser
3903
3934
  when 'q', 'ESC', "\e"
3904
3935
  @in_folder_browser = false
3936
+ @in_favorites_browser = false
3905
3937
  render_all
3906
3938
  break
3907
3939
  else
@@ -3918,6 +3950,7 @@ module Heathrow
3918
3950
  # Open a specific folder and show its messages
3919
3951
  def open_folder(folder_name)
3920
3952
  @in_folder_browser = false
3953
+ @in_favorites_browser = false
3921
3954
  @current_folder = folder_name
3922
3955
  @current_view = 'A'
3923
3956
  @in_source_view = false
@@ -3932,7 +3965,7 @@ module Heathrow
3932
3965
  @panes[:bottom].refresh
3933
3966
 
3934
3967
  # Light query with limit (full content loaded lazily when viewing)
3935
- @load_limit = 1000
3968
+ @load_limit = 200
3936
3969
  light_cols = "id, source_id, external_id, thread_id, parent_id, sender, sender_name, recipients, subject, substr(content, 1, 200) as content, timestamp, received_at, read AS is_read, starred AS is_starred, archived, labels, metadata, attachments, folder, replied"
3937
3970
  results = @db.execute(
3938
3971
  "SELECT #{light_cols} FROM messages WHERE folder >= ? AND folder < ? ORDER BY timestamp DESC LIMIT ?",
@@ -3969,6 +4002,7 @@ module Heathrow
3969
4002
  def show_favorites_browser
3970
4003
  favorites = get_favorite_folders
3971
4004
  @in_folder_browser = true
4005
+ @in_favorites_browser = true
3972
4006
  @folder_browser_index = 0
3973
4007
  @panes[:top].bg = @topcolor
3974
4008
  @folder_count_cache = {} # Fresh counts each time
@@ -3999,7 +4033,7 @@ module Heathrow
3999
4033
  when 'k', 'UP'
4000
4034
  @folder_browser_index = (@folder_browser_index - 1) % @folder_display.size if @folder_display.size > 0
4001
4035
  render_folder_browser
4002
- when 'ENTER'
4036
+ when 'l', 'RIGHT', 'ENTER'
4003
4037
  folder = @folder_display[@folder_browser_index]
4004
4038
  if folder
4005
4039
  open_folder(folder[:full_name])
@@ -4052,6 +4086,7 @@ module Heathrow
4052
4086
  end
4053
4087
  when 'q', 'ESC', "\e", 'h', 'LEFT'
4054
4088
  @in_folder_browser = false
4089
+ @in_favorites_browser = false
4055
4090
  render_all
4056
4091
  break
4057
4092
  end
@@ -4314,6 +4349,9 @@ module Heathrow
4314
4349
  @folder_collapsed.delete(folder[:full_name])
4315
4350
  @folder_display = flatten_folder_tree(@folder_tree, '', 0, @folder_collapsed)
4316
4351
  render_save_folder_picker(idx, title)
4352
+ elsif folder
4353
+ render_all
4354
+ return folder[:full_name]
4317
4355
  end
4318
4356
  when 'h', 'LEFT'
4319
4357
  folder = @folder_display[idx]
@@ -5679,23 +5717,30 @@ module Heathrow
5679
5717
 
5680
5718
  if result[:success]
5681
5719
  if orig_id
5720
+ # Re-read metadata from DB to get current maildir_file path
5721
+ # (poller may have renamed the file since we captured orig_msg)
5722
+ fresh = @db.get_message(orig_id)
5723
+ if fresh
5724
+ orig_msg = fresh
5725
+ end
5726
+ # Sync disk flag first, then DB, to avoid poller race condition
5727
+ sync_maildir_flag(orig_msg, 'R', true) if orig_msg
5682
5728
  @db.execute("UPDATE messages SET replied = 1 WHERE id = ?", [orig_id])
5683
5729
  orig_msg['replied'] = 1 if orig_msg
5684
- sync_maildir_flag(orig_msg, 'R', true) if orig_msg
5685
5730
  end
5686
5731
  msg = result[:message]
5687
5732
  if composed[:attachments] && !composed[:attachments].empty?
5688
5733
  msg += " (#{composed[:attachments].size} attachment(s))"
5689
5734
  end
5690
5735
  set_feedback(msg, 156, 3)
5736
+ render_left_pane if orig_id
5691
5737
  else
5692
5738
  set_feedback(result[:message], 196, 4)
5693
5739
  end
5694
5740
  rescue => e
5695
5741
  set_feedback("Send error: #{e.message}", 196, 4)
5696
- if ENV['DEBUG']
5697
- File.open('/tmp/heathrow_debug.log', 'a') { |f| f.puts "send_composed_message error: #{e.message}\n#{e.backtrace.first(5).join("\n")}" }
5698
- end
5742
+ File.open('/tmp/heathrow_debug.log', 'a') { |f| f.puts "#{Time.now} send_composed_message error: #{e.message}\n#{e.backtrace.first(5).join("\n")}" }
5743
+
5699
5744
  end
5700
5745
  end
5701
5746
  end
@@ -7670,30 +7715,51 @@ Required: URL, optional CSS selector
7670
7715
  Config.identity_for_folder(folder)
7671
7716
  end
7672
7717
 
7673
- # Colorize email content with mutt-style quote levels and signature dimming
7718
+ # Colorize email content with mutt-style quote levels and signature dimming.
7719
+ # Detects both ">" prefix quoting and indentation-based quoting (from HTML
7720
+ # emails rendered via w3m, where blockquotes become indented text).
7674
7721
  def colorize_email_content(content)
7675
7722
  quote_colors = [theme[:quote1] || 114, theme[:quote2] || 180,
7676
7723
  theme[:quote3] || 139, theme[:quote4] || 109]
7677
- sig_color = theme[:sig] || 242
7678
- link_color = theme[:accent] || 33
7724
+ sig_color = theme[:sig] || 5 # magenta (vim PreProc)
7725
+ link_color = theme[:link] || 4 # blue (vim String)
7726
+ email_color = theme[:email] || 5 # magenta (vim Special)
7679
7727
  in_signature = false
7728
+ indent_quote_level = 0 # tracks nesting from "wrote:" attribution lines
7680
7729
 
7681
- content.lines.map do |line|
7730
+ lines = content.lines
7731
+ result = []
7732
+ lines.each_with_index do |line, i|
7682
7733
  stripped = line.rstrip
7683
7734
  # Detect signature delimiter (RFC 3676: "-- " on its own line)
7684
7735
  if stripped == '-- ' || stripped == '--'
7685
7736
  in_signature = true
7686
- colorize_links(stripped, sig_color, link_color)
7737
+ indent_quote_level = 0
7738
+ result << colorize_links(stripped, sig_color, link_color)
7687
7739
  elsif in_signature
7688
- colorize_links(stripped, sig_color, link_color)
7740
+ result << colorize_links(stripped, sig_color, link_color)
7689
7741
  elsif stripped =~ /^(>{1,})\s?/
7690
7742
  level = $1.length
7691
7743
  color = quote_colors[[level - 1, quote_colors.length - 1].min]
7692
- colorize_links(stripped, color, link_color)
7744
+ result << colorize_links(stripped, color, link_color)
7693
7745
  else
7694
- colorize_links(stripped, nil, link_color)
7746
+ # Detect "wrote:" attribution lines (start of indented quote block)
7747
+ if stripped =~ /\bwrote:\s*$/
7748
+ indent_quote_level += 1
7749
+ color = quote_colors[[indent_quote_level - 1, quote_colors.length - 1].min]
7750
+ result << colorize_links(stripped, color, link_color)
7751
+ elsif indent_quote_level > 0 && stripped =~ /^\s{2,}/
7752
+ # Indented line inside a quote block
7753
+ color = quote_colors[[indent_quote_level - 1, quote_colors.length - 1].min]
7754
+ result << colorize_links(stripped, color, link_color)
7755
+ else
7756
+ # Non-indented line resets indent quoting
7757
+ indent_quote_level = 0 if indent_quote_level > 0 && !stripped.empty?
7758
+ result << colorize_links(stripped, nil, link_color)
7759
+ end
7695
7760
  end
7696
- end.join("\n")
7761
+ end
7762
+ result.join("\n")
7697
7763
  end
7698
7764
 
7699
7765
  # Convert HTML to readable text via w3m
@@ -7738,12 +7804,13 @@ Required: URL, optional CSS selector
7738
7804
  tags.each do |tag|
7739
7805
  src = tag[/src=["']([^"']+)["']/i, 1]
7740
7806
  next unless src
7741
- # Skip by URL patterns
7742
- next if src =~ /track|pixel|spacer|beacon|\.gif$/i
7743
- # Skip by HTML dimensions (1x1 tracking pixels, tiny icons)
7807
+ # Skip by URL patterns (tracking, icons, social media badges)
7808
+ next if src =~ /track|pixel|spacer|beacon|\.gif$|icon|logo|badge|button|social|facebook|linkedin|twitter|instagram/i
7809
+ # Skip by HTML dimensions (tracking pixels and small icons)
7744
7810
  w = tag[/width=["']?(\d+)/i, 1]&.to_i
7745
7811
  h = tag[/height=["']?(\d+)/i, 1]&.to_i
7746
- next if w && h && (w <= 2 || h <= 2)
7812
+ next if w && w <= 40
7813
+ next if h && h <= 40
7747
7814
  urls << src
7748
7815
  end
7749
7816
  urls
@@ -7825,7 +7892,7 @@ Required: URL, optional CSS selector
7825
7892
  # Clear right pane and show images
7826
7893
  n = image_paths.size
7827
7894
  label = n == 1 ? "1 image" : "#{n} images"
7828
- @panes[:right].text = " [#{label}] Press I to return".fg(245)
7895
+ @panes[:right].text = " [#{label}] Press ESC to return".fg(245)
7829
7896
  @panes[:right].full_refresh # Full refresh to clear image area
7830
7897
 
7831
7898
  pane_w = @panes[:right].w - 2
@@ -7908,7 +7975,6 @@ Required: URL, optional CSS selector
7908
7975
  def format_attachments(attachments)
7909
7976
  return nil unless attachments.is_a?(Array) && !attachments.empty?
7910
7977
  lines = []
7911
- lines << ("─" * 60).fg(238)
7912
7978
  lines << "Attachments:".b.fg(208)
7913
7979
  attachments.each_with_index do |att, i|
7914
7980
  name = att['name'] || att['filename'] || 'unnamed'
@@ -7929,6 +7995,17 @@ Required: URL, optional CSS selector
7929
7995
  end
7930
7996
 
7931
7997
  # Highlight URLs in a line, applying base_color to non-URL text
7998
+ # Truncate a string to fit within a given display width (CJK-aware)
7999
+ def truncate_to_width(str, max_width)
8000
+ w = 0
8001
+ str.each_char.with_index do |c, i|
8002
+ cw = Rcurses.display_width(c)
8003
+ return str[0...i] if w + cw > max_width
8004
+ w += cw
8005
+ end
8006
+ str
8007
+ end
8008
+
7932
8009
  def colorize_links(line, base_color, link_color)
7933
8010
  url_re = %r{https?://[^\s<>\[\]()]+}
7934
8011
  parts = line.split(url_re, -1)
@@ -533,7 +533,7 @@ module Heathrow
533
533
  if (source_type || msg['source_type']) == 'rss'
534
534
  sender = '' # Don't show sender for RSS
535
535
  else
536
- sender = sender[0..14] if sender.length > 15
536
+ sender = truncate_to_width(sender, 14) + '…' if Rcurses.display_width(sender) > 15
537
537
  end
538
538
 
539
539
  # For Discord/Slack channels, show content not channel name
@@ -558,14 +558,14 @@ module Heathrow
558
558
  if sender.empty?
559
559
  prefix = "#{unread} "
560
560
  else
561
- prefix = "#{unread} #{sender.ljust(15)} "
561
+ prefix = "#{unread} #{sender + ' ' * [15 - Rcurses.display_width(sender), 0].max} "
562
562
  end
563
563
 
564
564
  # Truncate content to fit single line (prefix + 2 for nflag+ind from finalize_line)
565
565
  pane_width = @panes[:left].w - 5
566
- available = pane_width - prefix.length
567
- if display_content && display_content.length > available && available > 0
568
- display_content = display_content[0..(available-2)] + "…"
566
+ available = pane_width - Rcurses.display_width(prefix)
567
+ if display_content && Rcurses.display_width(display_content) > available && available > 0
568
+ display_content = truncate_to_width(display_content, available - 1) + "…"
569
569
  end
570
570
 
571
571
  finalize_line(msg, selected, prefix, display_content, color)
@@ -574,17 +574,17 @@ module Heathrow
574
574
  # Format a thread reply
575
575
  def format_thread_reply(msg, selected, indent)
576
576
  sender = display_sender(msg)
577
- sender = sender[0..12] if sender.length > 12
578
-
577
+ sender = truncate_to_width(sender, 12) if Rcurses.display_width(sender) > 12
578
+
579
579
  content = msg['content'] || ''
580
580
  content = content.gsub(/\n/, ' ')
581
-
581
+
582
582
  # Truncate based on pane width
583
583
  pane_width = @panes[:left].w - 5
584
- used_space = 2 + indent.length + 3 + sender.length + 2 # arrow + indent + └─ + sender + :
584
+ used_space = 2 + Rcurses.display_width(indent) + 3 + Rcurses.display_width(sender) + 2
585
585
  available = pane_width - used_space
586
- if content.length > available && available > 0
587
- content = content[0..(available-2)] + "…"
586
+ if Rcurses.display_width(content) > available && available > 0
587
+ content = truncate_to_width(content, available - 1) + "…"
588
588
  end
589
589
 
590
590
  prefix = "#{indent}└─ #{sender}: "
@@ -599,17 +599,20 @@ module Heathrow
599
599
 
600
600
  timestamp = (parse_timestamp(msg['timestamp']) || "").ljust(6)
601
601
  sender_max = 15
602
- sender = sender.length > sender_max ? sender[0..sender_max-2] + '…' : sender.ljust(sender_max)
602
+ sdw = Rcurses.display_width(sender)
603
+ sender = sdw > sender_max ? truncate_to_width(sender, sender_max - 1) + '…' : sender + ' ' * [sender_max - sdw, 0].max
603
604
  child_indent = indent.empty? ? "" : " "
604
605
  prefix = "#{child_indent}#{timestamp} #{icon} #{sender} "
605
606
 
606
607
  content = msg['subject'] || msg['content'] || ''
608
+ content = content.dup if content.frozen?
609
+ content = content.encode('UTF-8', invalid: :replace, undef: :replace, replace: '?') rescue content.force_encoding('UTF-8').scrub('?')
607
610
  content = content.gsub(/\n/, ' ')
608
611
 
609
- used_space = prefix.length + 1
612
+ used_space = Rcurses.display_width(prefix) + 1
610
613
  available = pane_width - used_space
611
- if content.length > available && available > 0
612
- content = content[0..(available-2)] + "…"
614
+ if Rcurses.display_width(content) > available && available > 0
615
+ content = truncate_to_width(content, available - 1) + "…"
613
616
  end
614
617
 
615
618
  color = get_source_color(msg)
@@ -623,7 +626,8 @@ module Heathrow
623
626
 
624
627
  # Truncate sender to fixed width
625
628
  sender_max = 15
626
- sender = sender.length > sender_max ? sender[0..sender_max-2] + '…' : sender.ljust(sender_max)
629
+ sdw = Rcurses.display_width(sender)
630
+ sender = sdw > sender_max ? truncate_to_width(sender, sender_max - 1) + '…' : sender + ' ' * [sender_max - sdw, 0].max
627
631
 
628
632
  content = msg['content'] || ''
629
633
  content = content.gsub(/\n/, ' ')
@@ -633,9 +637,9 @@ module Heathrow
633
637
 
634
638
  # Truncate content to fit single line
635
639
  pane_width = @panes[:left].w - 5
636
- available = pane_width - prefix.length
637
- if content.length > available && available > 0
638
- content = content[0..(available-2)] + "…"
640
+ available = pane_width - Rcurses.display_width(prefix)
641
+ if Rcurses.display_width(content) > available && available > 0
642
+ content = truncate_to_width(content, available - 1) + "…"
639
643
  end
640
644
 
641
645
  color = get_source_color(msg)
@@ -1,3 +1,3 @@
1
1
  module Heathrow
2
- VERSION = '0.7.1'
2
+ VERSION = '0.7.3'
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: heathrow
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.1
4
+ version: 0.7.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Geir Isene
@@ -9,7 +9,7 @@ authors:
9
9
  autorequire:
10
10
  bindir: bin
11
11
  cert_chain: []
12
- date: 2026-03-17 00:00:00.000000000 Z
12
+ date: 2026-03-18 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: rcurses