@@ -164,21 +164,26 @@ def __init__(self, win):
164164 "CLI authenticated. Keep the window open to be notified." )
165165 page .add (ci )
166166
167- # A thin progress bar per long-running CI row, driven by real job-step
168- # data (pulsed through the opaque long steps like the IA upload). Sits
169- # left of the action button; hidden while idle. id(bar) -> pulse timer.
167+ # A full-width progress bar UNDER each long-running CI row, driven by
168+ # real job-step data. show_text carries "NN% · phase · ~ETA": the ETA
169+ # comes from the measured pace of THIS run (fraction/elapsed), never a
170+ # made-up timer, and the opaque IA-upload step pulses with an honest
171+ # "elapsed" instead of a fake percent. First cut was a 150px "osd"
172+ # sliver in the suffix — unreadable (Daniel, 2026-07-05).
173+ # id(bar) -> pulse-timer source; id(bar) -> {t0, f0} pace state.
170174 self ._pulse_ids = {}
175+ self ._bar_state = {}
171176
172177 def _ci_row (title , subtitle , btn_label , cb ):
173178 row = Adw .ActionRow (title = title , subtitle = subtitle )
174- bar = Gtk .ProgressBar (valign = Gtk .Align .CENTER , visible = False ,
175- width_request = 150 )
176- bar .add_css_class ("osd" )
177- row .add_suffix (bar )
178179 btn = Gtk .Button (label = btn_label , valign = Gtk .Align .CENTER )
179180 btn .connect ("clicked" , cb )
180181 row .add_suffix (btn )
181182 ci .add (row )
183+ bar = Gtk .ProgressBar (show_text = True , hexpand = True , visible = False ,
184+ margin_start = 12 , margin_end = 12 ,
185+ margin_top = 2 , margin_bottom = 8 )
186+ ci .add (bar )
182187 return row , btn , bar
183188
184189 self .base_row , self .base_btn , self .base_bar = _ci_row (
@@ -521,17 +526,50 @@ def got(ok, out, _err):
521526 ask ()
522527
523528 # -- progress bars -------------------------------------------------------
524- def _bar_frac (self , bar , f ):
525- """Show a determinate fraction (stops any pulsing)."""
529+ @staticmethod
530+ def _fmt_dur (seconds ):
531+ m = int (seconds // 60 )
532+ return f"{ m // 60 } h { m % 60 } m" if m >= 60 else f"{ m } m"
533+
534+ def _bar_pace (self , bar , f = None ):
535+ """Per-bar pace state: first call (or re-show) stamps t0 and the
536+ starting fraction, so the ETA reflects THIS run's measured speed."""
537+ st = self ._bar_state .get (id (bar ))
538+ if st is None or not bar .get_visible ():
539+ st = {"t0" : GLib .get_monotonic_time () / 1e6 ,
540+ "f0" : f if f is not None else 0.0 }
541+ self ._bar_state [id (bar )] = st
542+ return st , GLib .get_monotonic_time () / 1e6 - st ["t0" ]
543+
544+ def _bar_frac (self , bar , f , label = "" ):
545+ """Determinate bar with rich in-bar text: 'NN% · label · pace'.
546+ The ETA is extrapolated from this run's own progress rate — shown only
547+ once there's enough signal (>=90s and >=3% advanced) to be honest."""
548+ st , elapsed = self ._bar_pace (bar , f )
526549 self ._bar_stop_pulse (bar )
550+ f = max (0.0 , min (1.0 , f ))
551+ txt = f"{ int (f * 100 )} %"
552+ if label :
553+ txt += f" · { label } "
554+ adv = f - st ["f0" ]
555+ if elapsed >= 90 and adv >= 0.03 and f < 0.995 :
556+ eta = (1.0 - f ) * (elapsed / adv )
557+ txt += f" · { self ._fmt_dur (elapsed )} elapsed · ~{ self ._fmt_dur (eta )} left"
558+ elif elapsed >= 60 :
559+ txt += f" · { self ._fmt_dur (elapsed )} elapsed"
527560 bar .set_visible (True )
528- bar .set_fraction (max (0.0 , min (1.0 , f )))
529-
530- def _bar_pulse (self , bar ):
531- """Show an indeterminate, animating bar (for opaque long steps like the
532- IA upload, which GitHub exposes no sub-progress for). A 150ms ticker
533- keeps it lively between the slow 60s poll ticks."""
561+ bar .set_fraction (f )
562+ bar .set_text (txt )
563+
564+ def _bar_pulse (self , bar , label = "" ):
565+ """Indeterminate, animating bar for opaque long steps (the IA upload:
566+ GitHub exposes no sub-progress). The text says so honestly — phase +
567+ elapsed — and a 150ms ticker keeps it alive between the 60s polls."""
568+ st , elapsed = self ._bar_pace (bar )
534569 bar .set_visible (True )
570+ if label :
571+ bar .set_text (f"{ label } · { self ._fmt_dur (elapsed )} elapsed"
572+ if elapsed >= 60 else label )
535573 if id (bar ) in self ._pulse_ids :
536574 return
537575 def _p ():
@@ -546,6 +584,7 @@ def _bar_stop_pulse(self, bar):
546584
547585 def _bar_hide (self , bar ):
548586 self ._bar_stop_pulse (bar )
587+ self ._bar_state .pop (id (bar ), None )
549588 bar .set_fraction (0.0 )
550589 bar .set_visible (False )
551590
@@ -612,10 +651,11 @@ def tick(data):
612651 return True
613652 if data .get ("status" ) != "completed" :
614653 bf , _s = _step_frac (_jobdict (data , "" )) # primary job's steps
615- self ._bar_frac (self .base_bar , 0.05 + 0.60 * bf ) # build owns 5-65%
654+ self ._bar_frac (self .base_bar , 0.05 + 0.60 * bf ,
655+ "building :candidate" ) # build owns 5-65%
616656 return True
617657 if data .get ("conclusion" ) == "success" :
618- self ._bar_frac (self .base_bar , 0.65 )
658+ self ._bar_frac (self .base_bar , 0.65 , "waiting for smoke-boot" )
619659 self .base_row .set_subtitle (":candidate built — waiting for smoke-boot…" )
620660 self .win .notify ("Base :candidate built" ,
621661 "QEMU smoke-boot runs next; :stable follows if green." )
@@ -641,7 +681,8 @@ def sb_tick(data):
641681 return True
642682 if data .get ("status" ) != "completed" :
643683 sf , _s = _step_frac (_jobdict (data , "" ))
644- self ._bar_frac (self .base_bar , 0.65 + 0.35 * sf ) # smoke-boot 65-100%
684+ self ._bar_frac (self .base_bar , 0.65 + 0.35 * sf ,
685+ "smoke-boot + promote" ) # smoke-boot 65-100%
645686 return True
646687 if data .get ("conclusion" ) == "success" :
647688 self ._ci_base_done (":stable promoted" )
@@ -769,17 +810,18 @@ def tick(data):
769810 if pub_s == "in_progress" :
770811 pf , pstep = _step_frac (_jobdict (data , "Publish ISO" ))
771812 if any (k in pstep for k in ("Internet Archive" , "Upload" , "derive" )):
772- self ._bar_pulse (self .pub_bar )
813+ self ._bar_pulse (self .pub_bar ,
814+ "uploading to Internet Archive (no % from GitHub)" )
773815 else :
774- self ._bar_frac (self .pub_bar , 0.65 + 0.35 * pf )
816+ self ._bar_frac (self .pub_bar , 0.65 + 0.35 * pf , "publishing" )
775817 elif iso_s == "completed" and iso_c == "success" :
776818 gf , _gs = _step_frac (_jobdict (data , "Automated install gate" ))
777- self ._bar_frac (self .pub_bar , 0.50 + 0.15 * gf )
819+ self ._bar_frac (self .pub_bar , 0.50 + 0.15 * gf , "install gate" )
778820 elif iso_s == "in_progress" :
779821 isf , _is = _step_frac (_jobdict (data , "Build Live ISO" ))
780- self ._bar_frac (self .pub_bar , 0.50 * isf )
822+ self ._bar_frac (self .pub_bar , 0.50 * isf , "building ISO (zstd-19)" )
781823 else :
782- self ._bar_frac (self .pub_bar , 0.02 ) # queued
824+ self ._bar_frac (self .pub_bar , 0.02 , "queued" ) # queued
783825
784826 self .pub_row .set_subtitle (
785827 f"run { run_id } · iso: { _state (iso_s , iso_c )} · "
@@ -911,7 +953,7 @@ def progress():
911953 state ["rate" ] = (0.7 * state ["rate" ] + 0.3 * inst ) if state ["prev" ] else inst
912954 state ["prev" ] = have
913955 pct = min (99 , int (have * 100 / size )) if size else 0
914- self ._bar_frac (self .dl_bar , (pct or 0 ) / 100.0 )
956+ self ._bar_frac (self .dl_bar , (pct or 0 ) / 100.0 , "downloading" )
915957 extra = ""
916958 if state ["rate" ] > 1024 :
917959 left = (size - have ) / state ["rate" ] if size else 0
@@ -1063,7 +1105,7 @@ def progress():
10631105 state ["rate" ] = (0.7 * state ["rate" ] + 0.3 * inst ) if state ["prev" ] else inst
10641106 state ["prev" ] = have
10651107 pct = min (99 , int (have * 100 / size )) if size else 0
1066- self ._bar_frac (self .dl_bar , (pct or 0 ) / 100.0 )
1108+ self ._bar_frac (self .dl_bar , (pct or 0 ) / 100.0 , "downloading" )
10671109 extra = ""
10681110 if state ["rate" ] > 1024 :
10691111 left = (size - have ) / state ["rate" ] if size else 0
0 commit comments