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 +4 -4
- data/lib/heathrow/ui/application.rb +166 -54
- data/lib/heathrow/ui/threaded_view.rb +23 -19
- data/lib/heathrow/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5157385f8be9722ca5d2920debecea14751c93ba6bdba10840004ee6a9ae301a
|
|
4
|
+
data.tar.gz: f4eae36abc3446bc257c49ff60aca3ed6ef6b1bb981bb753aa9cc5884a74796d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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},
|
|
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({},
|
|
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({},
|
|
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
|
-
|
|
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
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2318
|
-
|
|
2319
|
-
|
|
2320
|
-
|
|
2321
|
-
|
|
2322
|
-
|
|
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 +=
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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,
|
|
5691
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
7744
|
-
# Skip by HTML dimensions (
|
|
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 &&
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
567
|
-
if display_content && display_content
|
|
568
|
-
display_content = display_content
|
|
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
|
|
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
|
|
584
|
+
used_space = 2 + Rcurses.display_width(indent) + 3 + Rcurses.display_width(sender) + 2
|
|
585
585
|
available = pane_width - used_space
|
|
586
|
-
if content
|
|
587
|
-
content = content
|
|
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
|
-
|
|
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
|
|
612
|
+
used_space = Rcurses.display_width(prefix) + 1
|
|
610
613
|
available = pane_width - used_space
|
|
611
|
-
if content
|
|
612
|
-
content = content
|
|
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
|
-
|
|
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
|
|
637
|
-
if content
|
|
638
|
-
content = content
|
|
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)
|
data/lib/heathrow/version.rb
CHANGED
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.
|
|
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-
|
|
12
|
+
date: 2026-03-20 00:00:00.000000000 Z
|
|
13
13
|
dependencies:
|
|
14
14
|
- !ruby/object:Gem::Dependency
|
|
15
15
|
name: rcurses
|