heathrow 0.7.2 → 0.7.4

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: cbe8be982ab3300b591a24bdba7bb67c9feff58167a7785fb288a4b62d558f46
4
- data.tar.gz: 513f905dff1e8a9e74b87488a1fa5b007d3a76e26287d7643961ea2b1b0b2268
3
+ metadata.gz: 5157385f8be9722ca5d2920debecea14751c93ba6bdba10840004ee6a9ae301a
4
+ data.tar.gz: f4eae36abc3446bc257c49ff60aca3ed6ef6b1bb981bb753aa9cc5884a74796d
5
5
  SHA512:
6
- metadata.gz: 29e42a3922e7dfb70188c3bc03345812ccb1a7a21c992384e1b50938a00418ecb6bdba009ba30d0020960b354f144b0ac1a9cc18910b2c2e775c0137a7f68c61
7
- data.tar.gz: 3a7a0e541c490c8d783f679d6be4bcab6dabe703041ea2620d95b7756ec74cc53d9bfee851e15f3863685168f8c33f1c1b227ecf5e1aea784681d4b12cfb6f16
6
+ metadata.gz: dd461568292aef77abb3ef404d6a7d4d8472ef225f4da407a0dedb604ce8c590b0649455053a56c2a18e4d6023bcba81fcfa5bafaf5498eefe1756e6ee5870a1
7
+ data.tar.gz: 97503edee0d70789701ccdccf02cbf7eff5aa91311deeca2c3d8d28197581ae243f1e04fb693b0780d34c1ae38162e3c1deb89feb34e8210105c76c246004f96
@@ -419,9 +419,10 @@ module Heathrow
419
419
  @initial_load_done = false
420
420
  Thread.new do
421
421
  begin
422
+ @load_limit = 200
422
423
  case @default_view
423
424
  when 'N'
424
- @filtered_messages = @db.get_messages({is_read: false}, 1000, 0, light: true)
425
+ @filtered_messages = @db.get_messages({is_read: false}, @load_limit, 0, light: true)
425
426
  when /^[0-9]$/, /^F\d+$/
426
427
  view = @views[@default_view]
427
428
  if view && view[:filters] && !view[:filters].empty?
@@ -430,16 +431,17 @@ module Heathrow
430
431
  end
431
432
  apply_view_filters(view)
432
433
  else
433
- @filtered_messages = @db.get_messages({}, 1000, 0, light: true)
434
+ @filtered_messages = @db.get_messages({}, @load_limit, 0, light: true)
434
435
  @current_view = 'A'
435
436
  end
436
437
  else
437
- @filtered_messages = @db.get_messages({}, 1000, 0, light: true)
438
+ @filtered_messages = @db.get_messages({}, @load_limit, 0, light: true)
438
439
  @current_view = 'A'
439
440
  end
440
441
  sort_messages
441
442
  @index = 0
442
443
  reset_threading if respond_to?(:reset_threading)
444
+ restore_view_thread_mode if respond_to?(:restore_view_thread_mode)
443
445
  @initial_load_done = true
444
446
  @needs_redraw = true
445
447
  # Preload the heavy mail gem so 'v' (attachments) doesn't lag
@@ -508,9 +510,17 @@ module Heathrow
508
510
  render_all
509
511
  @needs_redraw = false
510
512
  end
513
+ # Check for mailto trigger (from wezterm or external script)
514
+ check_mailto_trigger
515
+
511
516
  chr = getchr(2, flush: false) # 2s timeout to check for new mail
512
517
  begin
513
518
  if chr
519
+ # Clear sticky feedback (errors, "message sent") on any keypress
520
+ if @feedback_sticky
521
+ @feedback_sticky = false
522
+ @feedback_expires_at = Time.now # Expire now so render_bottom_bar clears it
523
+ end
514
524
  @needs_redraw = false # Handlers that need redraw call render_all directly
515
525
  handle_input_key(chr)
516
526
  else
@@ -725,6 +735,8 @@ module Heathrow
725
735
  page_down
726
736
  when 'PgUP'
727
737
  page_up
738
+ when 'L'
739
+ load_more_messages
728
740
  when 'w'
729
741
  change_width
730
742
  when 'Y'
@@ -1498,28 +1510,32 @@ module Heathrow
1498
1510
  available_width -= 2 if @panes[:left].border # Extra space for border chars
1499
1511
  available_width -= 3 # Space for N flag + replied flag + indicator column (tag/star/attachment/D)
1500
1512
 
1501
- # Truncate sender to fit
1513
+ # Truncate sender to fit (use display_width for CJK characters)
1502
1514
  sender_max = 15
1503
- sender_display = sender.length > sender_max ? sender[0..sender_max-2] + '…' : sender.ljust(sender_max)
1504
-
1515
+ dw = Rcurses.display_width(sender)
1516
+ sender_display = if dw > sender_max
1517
+ truncate_to_width(sender, sender_max - 1) + '…'
1518
+ else
1519
+ sender + ' ' * [sender_max - dw, 0].max
1520
+ end
1521
+
1505
1522
  # Build the line with timestamp, icon and sender
1506
1523
  icon = respond_to?(:get_source_icon) ? get_source_icon(msg['source_type']) : '•'
1507
1524
  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] + '…'
1525
+
1526
+ # Calculate remaining space for subject (use display_width for CJK)
1527
+ prefix_dw = Rcurses.display_width(line_prefix)
1528
+ subject_width = available_width - prefix_dw - 1 # -1 for safety
1529
+ subject_dw = Rcurses.display_width(subject)
1530
+ if subject_width > 0 && subject_dw > subject_width
1531
+ subject = truncate_to_width(subject, subject_width - 1) + '…'
1532
+ subject_dw = Rcurses.display_width(subject)
1513
1533
  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} "
1534
+
1535
+ prefix_part = line_prefix
1521
1536
  subject_part = subject.strip
1522
- padding = " " * [available_width - prefix_part.length - subject_part.length, 0].max
1537
+ total_dw = Rcurses.display_width(prefix_part) + Rcurses.display_width(subject_part)
1538
+ padding = " " * [available_width - total_dw, 0].max
1523
1539
  finalize_line(msg, selected, prefix_part, subject_part, source_color, padding)
1524
1540
  end
1525
1541
 
@@ -1885,7 +1901,7 @@ module Heathrow
1885
1901
  # Show view name instantly
1886
1902
  render_top_bar
1887
1903
 
1888
- @load_limit = 1000
1904
+ @load_limit = 200
1889
1905
  @filtered_messages = @db.get_messages({}, @load_limit, 0, light: true)
1890
1906
  sort_messages
1891
1907
  @index = 0
@@ -1911,7 +1927,11 @@ module Heathrow
1911
1927
  clear_inline_image
1912
1928
  end
1913
1929
 
1914
- return unless current_msg
1930
+ unless current_msg
1931
+ @panes[:right].text = ""
1932
+ @panes[:right].refresh
1933
+ return
1934
+ end
1915
1935
 
1916
1936
  msg = current_msg
1917
1937
 
@@ -2244,7 +2264,13 @@ module Heathrow
2244
2264
  def set_feedback(message, color = 156, duration = 3)
2245
2265
  @feedback_message = message
2246
2266
  @feedback_color = color
2247
- @feedback_expires_at = duration > 0 ? Time.now + duration : Time.now + 86400
2267
+ # duration 0 or errors: persist until next user action (cleared by clear_sticky_feedback)
2268
+ @feedback_sticky = (duration == 0 || color == 196)
2269
+ @feedback_expires_at = if @feedback_sticky
2270
+ nil # Never auto-expire; cleared on keypress
2271
+ else
2272
+ Time.now + duration
2273
+ end
2248
2274
  if @panes[:bottom]
2249
2275
  @panes[:bottom].text = " #{message}".fg(color)
2250
2276
  @panes[:bottom].refresh
@@ -2314,18 +2340,22 @@ module Heathrow
2314
2340
 
2315
2341
  def check_load_more
2316
2342
  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
2343
+ return if @filtered_messages.size < @load_limit # Haven't hit the limit yet
2344
+ display_size = message_count
2345
+ return if display_size == 0
2346
+ return unless @index >= display_size - 10
2347
+ return if @last_autoload_index == @index
2348
+ @last_autoload_index = @index
2349
+ load_more_messages
2323
2350
  end
2324
2351
 
2325
2352
  def load_more_messages
2326
2353
  return unless @load_limit
2354
+ # Remember current message so we can restore position after reload
2355
+ cur = current_message
2356
+ cur_id = cur['id'] if cur
2327
2357
  old_count = @filtered_messages.size
2328
- @load_limit += 1000
2358
+ @load_limit += 200
2329
2359
 
2330
2360
  if @current_folder
2331
2361
  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 +2375,21 @@ module Heathrow
2345
2375
 
2346
2376
  sort_messages
2347
2377
  new_count = @filtered_messages.size
2348
- set_feedback("Loaded #{new_count} messages (+#{new_count - old_count})", 156, 2) if new_count > old_count
2378
+ return if new_count <= old_count
2379
+
2380
+ # In threaded mode, set pending restore so the threaded rebuild finds our position
2381
+ if @show_threaded && cur_id
2382
+ @pending_restore_id = cur_id
2383
+ end
2384
+
2385
+ # Force threaded view to rebuild organizer with new messages
2386
+ if @show_threaded && respond_to?(:organize_current_messages)
2387
+ organize_current_messages(true)
2388
+ end
2389
+
2390
+ set_feedback("Loaded #{new_count} messages (+#{new_count - old_count})", 156, 2)
2391
+ render_message_list
2392
+ render_top_bar
2349
2393
  end
2350
2394
 
2351
2395
  # Ensure all feeds/channels from a source have at least some messages loaded
@@ -2512,7 +2556,7 @@ module Heathrow
2512
2556
 
2513
2557
  render_top_bar
2514
2558
 
2515
- @load_limit = 1000
2559
+ @load_limit = 200
2516
2560
  @filtered_messages = @db.get_messages({is_read: false}, @load_limit, 0, light: true)
2517
2561
  sort_messages
2518
2562
  @index = 0
@@ -2559,7 +2603,7 @@ module Heathrow
2559
2603
  @section_order = view[:filters]['section_order'].dup
2560
2604
  end
2561
2605
 
2562
- @load_limit = 1000
2606
+ @load_limit = 200
2563
2607
  if view && view[:filters] && !view[:filters].empty?
2564
2608
  apply_view_filters(view)
2565
2609
  sort_messages
@@ -2999,6 +3043,7 @@ module Heathrow
2999
3043
  msg = current_message
3000
3044
  return unless msg
3001
3045
  return if msg['is_header'] || msg['is_channel_header'] || msg['is_thread_header']
3046
+ msg = ensure_full_message(msg)
3002
3047
 
3003
3048
  # Mark as read
3004
3049
  if msg['is_read'].to_i == 0
@@ -3701,6 +3746,7 @@ module Heathrow
3701
3746
 
3702
3747
  def show_folder_browser
3703
3748
  @in_folder_browser = true
3749
+ @in_favorites_browser = false
3704
3750
  @folder_browser_index = 0
3705
3751
  @panes[:top].bg = @topcolor
3706
3752
  @folder_collapsed ||= {}
@@ -3781,8 +3827,10 @@ module Heathrow
3781
3827
  @panes[:right].refresh
3782
3828
  end
3783
3829
 
3784
- # Update top bar
3785
- @panes[:top].text = " Heathrow - ".b.fg(255) + "Folder Browser".b.fg(201) + " [#{@folder_display.size} folders]".fg(246)
3830
+ # Update top bar (preserve Favorites title if in favorites mode)
3831
+ browser_title = @in_favorites_browser ? "Favorites" : "Folder Browser"
3832
+ browser_color = @in_favorites_browser ? 226 : 201
3833
+ @panes[:top].text = " Heathrow - ".b.fg(255) + browser_title.b.fg(browser_color) + " [#{@folder_display.size} folders]".fg(246)
3786
3834
  @panes[:top].refresh
3787
3835
 
3788
3836
  # Update bottom bar
@@ -3902,6 +3950,7 @@ module Heathrow
3902
3950
  render_folder_browser
3903
3951
  when 'q', 'ESC', "\e"
3904
3952
  @in_folder_browser = false
3953
+ @in_favorites_browser = false
3905
3954
  render_all
3906
3955
  break
3907
3956
  else
@@ -3918,6 +3967,7 @@ module Heathrow
3918
3967
  # Open a specific folder and show its messages
3919
3968
  def open_folder(folder_name)
3920
3969
  @in_folder_browser = false
3970
+ @in_favorites_browser = false
3921
3971
  @current_folder = folder_name
3922
3972
  @current_view = 'A'
3923
3973
  @in_source_view = false
@@ -3932,7 +3982,7 @@ module Heathrow
3932
3982
  @panes[:bottom].refresh
3933
3983
 
3934
3984
  # Light query with limit (full content loaded lazily when viewing)
3935
- @load_limit = 1000
3985
+ @load_limit = 200
3936
3986
  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
3987
  results = @db.execute(
3938
3988
  "SELECT #{light_cols} FROM messages WHERE folder >= ? AND folder < ? ORDER BY timestamp DESC LIMIT ?",
@@ -3969,6 +4019,7 @@ module Heathrow
3969
4019
  def show_favorites_browser
3970
4020
  favorites = get_favorite_folders
3971
4021
  @in_folder_browser = true
4022
+ @in_favorites_browser = true
3972
4023
  @folder_browser_index = 0
3973
4024
  @panes[:top].bg = @topcolor
3974
4025
  @folder_count_cache = {} # Fresh counts each time
@@ -3999,7 +4050,7 @@ module Heathrow
3999
4050
  when 'k', 'UP'
4000
4051
  @folder_browser_index = (@folder_browser_index - 1) % @folder_display.size if @folder_display.size > 0
4001
4052
  render_folder_browser
4002
- when 'ENTER'
4053
+ when 'l', 'RIGHT', 'ENTER'
4003
4054
  folder = @folder_display[@folder_browser_index]
4004
4055
  if folder
4005
4056
  open_folder(folder[:full_name])
@@ -4052,6 +4103,7 @@ module Heathrow
4052
4103
  end
4053
4104
  when 'q', 'ESC', "\e", 'h', 'LEFT'
4054
4105
  @in_folder_browser = false
4106
+ @in_favorites_browser = false
4055
4107
  render_all
4056
4108
  break
4057
4109
  end
@@ -4314,6 +4366,9 @@ module Heathrow
4314
4366
  @folder_collapsed.delete(folder[:full_name])
4315
4367
  @folder_display = flatten_folder_tree(@folder_tree, '', 0, @folder_collapsed)
4316
4368
  render_save_folder_picker(idx, title)
4369
+ elsif folder
4370
+ render_all
4371
+ return folder[:full_name]
4317
4372
  end
4318
4373
  when 'h', 'LEFT'
4319
4374
  folder = @folder_display[idx]
@@ -5286,7 +5341,24 @@ module Heathrow
5286
5341
  render_bottom_bar
5287
5342
  end
5288
5343
 
5289
- def compose_new_mail(source)
5344
+ # Check for mailto trigger file (written by wezterm or external scripts)
5345
+ def check_mailto_trigger
5346
+ mailto_file = File.join(HEATHROW_HOME, 'mailto')
5347
+ return unless File.exist?(mailto_file)
5348
+ addr = File.read(mailto_file).strip
5349
+ File.delete(mailto_file)
5350
+ return if addr.empty?
5351
+
5352
+ # Find the first mail source
5353
+ mail_source = @source_manager.sources.values.find do |s|
5354
+ s['enabled'] && %w[maildir gmail imap].include?(s['plugin_type'] || s['type'])
5355
+ end
5356
+ return unless mail_source
5357
+
5358
+ compose_new_mail(mail_source, mailto: addr)
5359
+ end
5360
+
5361
+ def compose_new_mail(source, mailto: nil)
5290
5362
  require_relative '../message_composer'
5291
5363
  identity = current_identity
5292
5364
 
@@ -5309,7 +5381,7 @@ module Heathrow
5309
5381
  @panes[:bottom].text = " Opening editor for new message...".fg(226)
5310
5382
  @panes[:bottom].refresh
5311
5383
 
5312
- composed = composer.compose_new
5384
+ composed = composer.compose_new(mailto)
5313
5385
  end
5314
5386
  setup_display
5315
5387
  create_panes
@@ -5679,16 +5751,23 @@ module Heathrow
5679
5751
 
5680
5752
  if result[:success]
5681
5753
  if orig_id
5754
+ # Re-read metadata from DB to get current maildir_file path
5755
+ # (poller may have renamed the file since we captured orig_msg)
5756
+ fresh = @db.get_message(orig_id)
5757
+ if fresh
5758
+ orig_msg = fresh
5759
+ end
5760
+ # Sync disk flag first, then DB, to avoid poller race condition
5761
+ sync_maildir_flag(orig_msg, 'R', true) if orig_msg
5682
5762
  @db.execute("UPDATE messages SET replied = 1 WHERE id = ?", [orig_id])
5683
5763
  orig_msg['replied'] = 1 if orig_msg
5684
- sync_maildir_flag(orig_msg, 'R', true) if orig_msg
5685
5764
  end
5686
5765
  msg = result[:message]
5687
5766
  if composed[:attachments] && !composed[:attachments].empty?
5688
5767
  msg += " (#{composed[:attachments].size} attachment(s))"
5689
5768
  end
5690
- set_feedback(msg, 156, 3)
5691
- render_left_pane if orig_id
5769
+ set_feedback(msg, 156, 0)
5770
+ render_message_list if orig_id
5692
5771
  else
5693
5772
  set_feedback(result[:message], 196, 4)
5694
5773
  end
@@ -7670,7 +7749,9 @@ Required: URL, optional CSS selector
7670
7749
  Config.identity_for_folder(folder)
7671
7750
  end
7672
7751
 
7673
- # Colorize email content with mutt-style quote levels and signature dimming
7752
+ # Colorize email content with mutt-style quote levels and signature dimming.
7753
+ # Detects both ">" prefix quoting and indentation-based quoting (from HTML
7754
+ # emails rendered via w3m, where blockquotes become indented text).
7674
7755
  def colorize_email_content(content)
7675
7756
  quote_colors = [theme[:quote1] || 114, theme[:quote2] || 180,
7676
7757
  theme[:quote3] || 139, theme[:quote4] || 109]
@@ -7678,23 +7759,43 @@ Required: URL, optional CSS selector
7678
7759
  link_color = theme[:link] || 4 # blue (vim String)
7679
7760
  email_color = theme[:email] || 5 # magenta (vim Special)
7680
7761
  in_signature = false
7762
+ indent_quote_level = 0 # tracks nesting from "wrote:" attribution lines
7681
7763
 
7682
- content.lines.map do |line|
7764
+ lines = content.lines
7765
+ result = []
7766
+ lines.each_with_index do |line, i|
7683
7767
  stripped = line.rstrip
7684
7768
  # Detect signature delimiter (RFC 3676: "-- " on its own line)
7685
7769
  if stripped == '-- ' || stripped == '--'
7686
7770
  in_signature = true
7687
- colorize_links(stripped, sig_color, link_color)
7771
+ indent_quote_level = 0
7772
+ result << colorize_links(stripped, sig_color, link_color)
7688
7773
  elsif in_signature
7689
- colorize_links(stripped, sig_color, link_color)
7774
+ result << colorize_links(stripped, sig_color, link_color)
7690
7775
  elsif stripped =~ /^(>{1,})\s?/
7691
7776
  level = $1.length
7692
7777
  color = quote_colors[[level - 1, quote_colors.length - 1].min]
7693
- colorize_links(stripped, color, link_color)
7778
+ result << colorize_links(stripped, color, link_color)
7694
7779
  else
7695
- colorize_links(stripped, nil, link_color)
7780
+ # Detect attribution lines (start of indented quote block)
7781
+ # Matches: "wrote:", "skrev ...:", "schrieb ...:", "a écrit :", or "date ... <email>:"
7782
+ if stripped =~ /\b(wrote|skrev|schrieb|geschreven|scrisse|escribi[oó]|a\s+[eé]crit)\b.*:\s*$/ ||
7783
+ stripped =~ /\d\d[:\.]\d\d\s.*<[^>]+>:\s*$/
7784
+ indent_quote_level += 1
7785
+ color = quote_colors[[indent_quote_level - 1, quote_colors.length - 1].min]
7786
+ result << colorize_links(stripped, color, link_color)
7787
+ elsif indent_quote_level > 0 && stripped =~ /^\s{2,}/
7788
+ # Indented line inside a quote block
7789
+ color = quote_colors[[indent_quote_level - 1, quote_colors.length - 1].min]
7790
+ result << colorize_links(stripped, color, link_color)
7791
+ else
7792
+ # Non-indented line resets indent quoting
7793
+ indent_quote_level = 0 if indent_quote_level > 0 && !stripped.empty?
7794
+ result << colorize_links(stripped, nil, link_color)
7795
+ end
7696
7796
  end
7697
- end.join("\n")
7797
+ end
7798
+ result.join("\n")
7698
7799
  end
7699
7800
 
7700
7801
  # Convert HTML to readable text via w3m
@@ -7739,12 +7840,13 @@ Required: URL, optional CSS selector
7739
7840
  tags.each do |tag|
7740
7841
  src = tag[/src=["']([^"']+)["']/i, 1]
7741
7842
  next unless src
7742
- # Skip by URL patterns
7743
- next if src =~ /track|pixel|spacer|beacon|\.gif$/i
7744
- # Skip by HTML dimensions (1x1 tracking pixels, tiny icons)
7843
+ # Skip by URL patterns (tracking, icons, social media badges)
7844
+ next if src =~ /track|pixel|spacer|beacon|\.gif$|icon|logo|badge|button|social|facebook|linkedin|twitter|instagram/i
7845
+ # Skip by HTML dimensions (tracking pixels and small icons)
7745
7846
  w = tag[/width=["']?(\d+)/i, 1]&.to_i
7746
7847
  h = tag[/height=["']?(\d+)/i, 1]&.to_i
7747
- next if w && h && (w <= 2 || h <= 2)
7848
+ next if w && w <= 40
7849
+ next if h && h <= 40
7748
7850
  urls << src
7749
7851
  end
7750
7852
  urls
@@ -7826,7 +7928,7 @@ Required: URL, optional CSS selector
7826
7928
  # Clear right pane and show images
7827
7929
  n = image_paths.size
7828
7930
  label = n == 1 ? "1 image" : "#{n} images"
7829
- @panes[:right].text = " [#{label}] Press I to return".fg(245)
7931
+ @panes[:right].text = " [#{label}] Press ESC to return".fg(245)
7830
7932
  @panes[:right].full_refresh # Full refresh to clear image area
7831
7933
 
7832
7934
  pane_w = @panes[:right].w - 2
@@ -7909,7 +8011,6 @@ Required: URL, optional CSS selector
7909
8011
  def format_attachments(attachments)
7910
8012
  return nil unless attachments.is_a?(Array) && !attachments.empty?
7911
8013
  lines = []
7912
- lines << ("─" * 60).fg(238)
7913
8014
  lines << "Attachments:".b.fg(208)
7914
8015
  attachments.each_with_index do |att, i|
7915
8016
  name = att['name'] || att['filename'] || 'unnamed'
@@ -7930,6 +8031,17 @@ Required: URL, optional CSS selector
7930
8031
  end
7931
8032
 
7932
8033
  # Highlight URLs in a line, applying base_color to non-URL text
8034
+ # Truncate a string to fit within a given display width (CJK-aware)
8035
+ def truncate_to_width(str, max_width)
8036
+ w = 0
8037
+ str.each_char.with_index do |c, i|
8038
+ cw = Rcurses.display_width(c)
8039
+ return str[0...i] if w + cw > max_width
8040
+ w += cw
8041
+ end
8042
+ str
8043
+ end
8044
+
7933
8045
  def colorize_links(line, base_color, link_color)
7934
8046
  url_re = %r{https?://[^\s<>\[\]()]+}
7935
8047
  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.2'
2
+ VERSION = '0.7.4'
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.2
4
+ version: 0.7.4
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-18 00:00:00.000000000 Z
12
+ date: 2026-03-20 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: rcurses