1818INITIAL_TAIL_SIZE = 256 * 1024 # 256KB
1919
2020
21+ def _apply_carriage_returns (line : str ) -> str :
22+ """Collapse a line as a terminal would: keep only content after the last \\ r.
23+
24+ tqdm-like progress bars rewrite the same line via \\ r; treat that as
25+ overwrite rather than as a new line.
26+ """
27+ if "\r " in line :
28+ return line .rsplit ("\r " , 1 )[- 1 ]
29+ return line
30+
31+
2132class LogFile :
2233 """Efficient log file reader that tracks position and watches for changes"""
2334
2435 def __init__ (self , path : str ):
2536 self .path = Path (path )
2637 self .position = 0
2738 self .size = 0
39+ # Buffer for the last incomplete line (no trailing \n yet).
40+ # Stored already collapsed for any \r updates seen so far.
41+ self ._partial_line = ""
2842 self ._update_size ()
2943
3044 def _update_size (self ) -> None :
@@ -34,17 +48,35 @@ def _update_size(self) -> None:
3448 except OSError :
3549 self .size = 0
3650
37- def read_tail (self , max_bytes : int = INITIAL_TAIL_SIZE ) -> str :
38- """Read the last N bytes of the file"""
51+ def _process_content (self , content : str ) -> tuple [list [str ], str ]:
52+ """Split a chunk into completed lines plus a trailing partial line.
53+
54+ Carriage returns are handled like a terminal would: within each
55+ \\ n-terminated line, only the substring after the last \\ r is kept.
56+ """
57+ combined = self ._partial_line + content
58+ parts = combined .split ("\n " )
59+ # parts[-1] is the partial trailing line (empty if combined ended in \n)
60+ complete = [_apply_carriage_returns (line ) for line in parts [:- 1 ]]
61+ partial = _apply_carriage_returns (parts [- 1 ])
62+ self ._partial_line = partial
63+ return complete , partial
64+
65+ def read_tail (self , max_bytes : int = INITIAL_TAIL_SIZE ) -> tuple [list [str ], str ]:
66+ """Read the last N bytes of the file.
67+
68+ Returns ``(complete_lines, partial_line)``. ``partial_line`` is the
69+ in-progress last line (no trailing \\ n yet) and may be empty.
70+ """
3971 if not self .path .exists ():
40- return ""
72+ return [], ""
4173
4274 self ._update_size ()
4375 if self .size == 0 :
44- return ""
76+ return [], ""
4577
4678 try :
47- with open (self .path , "r" , errors = "replace" ) as f :
79+ with open (self .path , "r" , errors = "replace" , newline = "" ) as f :
4880 # Start from max_bytes before end, or beginning
4981 start_pos = max (0 , self .size - max_bytes )
5082 f .seek (start_pos )
@@ -55,32 +87,40 @@ def read_tail(self, max_bytes: int = INITIAL_TAIL_SIZE) -> str:
5587
5688 content = f .read ()
5789 self .position = f .tell ()
58- return content
90+ # Reset partial buffer since we're reading from a known boundary
91+ self ._partial_line = ""
92+ return self ._process_content (content )
5993 except Exception :
60- return ""
94+ return [], ""
95+
96+ def read_new_content (self ) -> tuple [list [str ], str ]:
97+ """Read any new content since last read.
6198
62- def read_new_content (self ) -> str :
63- """Read any new content since last read"""
99+ Returns ``(new_complete_lines, partial_line)``. ``partial_line`` is the
100+ current in-progress last line (which may have changed even when no new
101+ complete lines were produced).
102+ """
64103 if not self .path .exists ():
65- return ""
104+ return [], self . _partial_line
66105
67106 self ._update_size ()
68107
69108 # File was truncated or rotated
70109 if self .size < self .position :
71110 self .position = 0
111+ self ._partial_line = ""
72112
73113 if self .position >= self .size :
74- return ""
114+ return [], self . _partial_line
75115
76116 try :
77- with open (self .path , "r" , errors = "replace" ) as f :
117+ with open (self .path , "r" , errors = "replace" , newline = "" ) as f :
78118 f .seek (self .position )
79119 content = f .read ()
80120 self .position = f .tell ()
81- return content
121+ return self . _process_content ( content )
82122 except Exception :
83- return ""
123+ return [], self . _partial_line
84124
85125 def has_new_content (self ) -> bool :
86126 """Check if there's new content without reading it"""
@@ -96,29 +136,39 @@ def __init__(self, file_path: str, widget_id: str):
96136 self .file_path = file_path
97137 self .log_file = LogFile (file_path )
98138 self .following = True
139+ self ._last_partial = ""
99140
100141 def compose (self ) -> ComposeResult :
101142 yield Static (f"📄 { self .file_path } " , classes = "log-file-path" )
102143 yield RichLog (id = f"{ self .id } -content" , wrap = True , highlight = True , markup = False )
144+ yield Static ("" , id = f"{ self .id } -partial" , classes = "log-partial" )
103145
104- def on_mount (self ) -> None :
105- """Load initial content from tail of file"""
106- content = self .log_file .read_tail ()
107- if content :
146+ def _apply_update (self , complete_lines : list [str ], partial : str ) -> None :
147+ if complete_lines :
108148 log_widget = self .query_one (f"#{ self .id } -content" , RichLog )
109- for line in content . splitlines () :
149+ for line in complete_lines :
110150 log_widget .write (line )
151+ if partial != self ._last_partial :
152+ partial_widget = self .query_one (f"#{ self .id } -partial" , Static )
153+ partial_widget .update (partial )
154+ partial_widget .display = bool (partial )
155+ self ._last_partial = partial
156+
157+ def on_mount (self ) -> None :
158+ """Load initial content from tail of file"""
159+ complete_lines , partial = self .log_file .read_tail ()
160+ # Hide partial widget when there is nothing in progress
161+ partial_widget = self .query_one (f"#{ self .id } -partial" , Static )
162+ partial_widget .display = bool (partial )
163+ self ._apply_update (complete_lines , partial )
111164
112165 def refresh_content (self ) -> None :
113166 """Check for and append new content"""
114167 if not self .following :
115168 return
116169
117- new_content = self .log_file .read_new_content ()
118- if new_content :
119- log_widget = self .query_one (f"#{ self .id } -content" , RichLog )
120- for line in new_content .splitlines ():
121- log_widget .write (line )
170+ complete_lines , partial = self .log_file .read_new_content ()
171+ self ._apply_update (complete_lines , partial )
122172
123173 def scroll_to_end (self ) -> None :
124174 """Scroll to end of log"""
@@ -175,6 +225,14 @@ class LogViewerScreen(Screen, inherit_bindings=False):
175225 height: 1fr;
176226 border: solid $primary;
177227 }
228+
229+ .log-partial {
230+ background: $boost;
231+ padding: 0 1;
232+ height: auto;
233+ color: $text;
234+ text-style: italic;
235+ }
178236 """
179237
180238 BINDINGS = [
0 commit comments