chiark / gitweb /
@@@ tty mess
[mLib] / ui / ttyprogress.c
1 /* -*-c-*-
2  *
3  * Progress bars for terminal programs
4  *
5  * (c) 2025 Straylight/Edgeware
6  */
7
8 /*----- Licensing notice --------------------------------------------------*
9  *
10  * This file is part of the mLib utilities library.
11  *
12  * mLib is free software: you can redistribute it and/or modify it under
13  * the terms of the GNU Library General Public License as published by
14  * the Free Software Foundation; either version 2 of the License, or (at
15  * your option) any later version.
16  *
17  * mLib is distributed in the hope that it will be useful, but WITHOUT
18  * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
19  * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU Library General Public
20  * License for more details.
21  *
22  * You should have received a copy of the GNU Library General Public
23  * License along with mLib.  If not, write to the Free Software
24  * Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307,
25  * USA.
26  */
27
28 /*----- Header files ------------------------------------------------------*/
29
30 #define _XOPEN_SOURCE
31 #include "config.h"
32
33 #include <assert.h>
34 #include <limits.h>
35 #include <stdarg.h>
36 #include <stdlib.h>
37 #include <string.h>
38 #include <wchar.h>
39
40 #include "alloc.h"
41 #include "arena.h"
42 #include "dstr.h"
43 #include "gprintf.h"
44 #include "growbuf.h"
45 #include "tty.h"
46 #include "ttycolour.h"
47 #include "ttyprogress.h"
48
49 /*----- Main code ---------------------------------------------------------*/
50
51 int ttyprogress_init(struct ttyprogress *progress, struct tty *tty)
52 {
53 #define GENATTR(want, attrs, fgspc, fgcol, bgspc, bgcol)                \
54   { (want), (want),                                                     \
55     { ((fgspc) << TTAF_FGSPCSHIFT) |                                    \
56       ((bgspc) << TTAF_BGSPCSHIFT) | (attrs),                           \
57       0, (fgcol), (bgcol) } }
58
59 #define FGBG(want, attrs, fgcol, bgcol)                                 \
60   GENATTR(want | TTACF_FG | TTACF_BG, attrs,                            \
61           TTCSPC_1BPCBR, TTCOL_##fgcol, TTCSPC_1BPCBR, TTCOL_##bgcol)
62 #define ATTR(want, attrs)                                               \
63   GENATTR(want, attrs, TTCSPC_NONE, 0, TTCSPC_NONE, 0)
64
65 #define BOLD (TTWT_BOLD << TTAF_WTSHIFT)
66
67   static const struct tty_attrlist
68     bbar_attrs[] = { FGBG(0, 0, BLK, GRN), ATTR(0, TTAF_INVV) },
69     ebar_attrs[] = { FGBG(0, 0, BLK, YLW), TTY_ATTRLIST_CLEAR },
70     note_attrs[] = { FGBG(0, BOLD, WHT, BLU), ATTR(0, TTAF_INVV | BOLD) },
71     warn_attrs[] = { FGBG(0, BOLD, WHT, MGN), ATTR(0, TTAF_INVV | BOLD) },
72     err_attrs[] = { FGBG(0, BOLD, WHT, RED), ATTR(0, TTAF_INVV | BOLD) };
73   static const struct ttycolour_style hltab[] =
74     TTYCOLOUR_INITTAB(TTYPROGRESS_HIGHLIGHTS);
75
76 #undef GENATTR
77 #undef FGBG
78 #undef ATTR
79 #undef BOLD
80
81   /* Clear the progress state. */
82   progress->tty = tty;
83   progress->items = progress->end_item = 0;
84   progress->nitems = 0; progress->last_lines = 0;
85   progress->line.a = arena_global; dstr_create(&progress->line.t);
86   progress->line.p = 0; progress->line.sz = 0;
87   progress->tv_update.tv_sec = 0; progress->tv_update.tv_usec = 0;
88
89   /* Check that the terminal is sufficiently cromulent. */
90   if (!tty || !(tty->ocaps&TTCF_RELMV) || !(tty->ocaps&TTCF_EREOD))
91     { progress->tty = 0; return (-1); }
92
93   /* Configure the highlight attributes. */
94   ttycolour_config(progress->attr,
95                    "MLIB_TTYPROGRESS_COLOURS", TCIF_GETENV, tty, hltab);
96
97   /* All done. */
98   return (0);
99 }
100
101 void ttyprogress_free(struct ttyprogress *progress)
102 {
103   dstr_destroy(&progress->line.t);
104   x_free(progress->line.a, progress->line.p);
105 }
106
107 /*----- Active item list maintenance --------------------------------------*/
108
109 int ttyprogress_additem(struct ttyprogress *progress,
110                      struct ttyprogress_item *item)
111 {
112   if (item->parent) return (-1);
113   item->prev = progress->end_item; item->next = 0;
114   if (progress->end_item) progress->end_item->next = item;
115   else progress->items = item;
116   progress->end_item = item; item->parent = progress;
117   progress->nitems++;
118
119   return (0);
120 }
121
122 int ttyprogress_removeitem(struct ttyprogress *progress,
123                         struct ttyprogress_item *item)
124 {
125   if (!item->parent) return (-1);
126   if (item->next) item->next->prev = item->prev;
127   else (progress->end_item) = item->prev;
128   if (item->prev) item->prev->next = item->next;
129   else (progress->items) = item->next;
130   progress->nitems--; item->parent = 0;
131
132   return (0);
133 }
134
135 /*----- Render state lifecycle --------------------------------------------*/
136
137 static void setup_render_state(struct ttyprogress *progress,
138                                struct ttyprogress_render *render)
139 {
140   struct tty *tty = progress->tty;
141
142   /* Clear everything. */
143   render->tty = tty;
144
145   /* Update the current terminal size. */
146   tty_resized(tty);
147
148   /* We'll render progress bars with colour or standout if we can; otherwise,
149    * we'll just insert a `|' in the right place, but that takes up an extra
150    * column, so deduct one from the terminal's width to compensate.  Deduct
151    * another one if the terminal doesn't let us leave the cursor in the final
152    * column without scrolling or wrapping.
153    */
154   render->width = tty->wd;
155   if (render->width && !(tty->acaps&(TTCF_MMARG | TTMF_AUTOM)))
156     render->width--;
157   if (render->width && !(tty->acaps&(TTACF_BG | TTACF_INVV)))
158     render->width--;
159
160   /* Borrow the line buffer and highlight table from the master state. */
161   render->line = &progress->line;
162   render->attr = progress->attr;
163 }
164
165 /*----- Measuring string widths -------------------------------------------*/
166
167 #if defined(HAVE_MBRTOWC) && defined(HAVE_WCWIDTH)
168
169 #include <wchar.h>
170
171 #define CONV_MORE ((size_t)-2)
172 #define CONV_BAD ((size_t)-1)
173
174 struct measure {
175   mbstate_t ps;                         /* conversion state */
176   const char *p; size_t i, sz;          /* input string, and cursor */
177   unsigned wd;                          /* width accumulated so far */
178 };
179
180 static void init_measure(struct measure *m, const char *p, size_t sz)
181         /* Set up M to measure the SZ-byte string P. */
182 {
183   m->p = p; m->sz = sz; m->i = 0; m->wd = 0;
184   memset(&m->ps, 0, sizeof(m->ps));
185 }
186
187 static int advance_measure(struct measure *m)
188         /* Advance the measurement in M by one character.  Return zero if the
189          * end of the string has been reached, or nonzero if there is more to
190          * come.
191          */
192 {
193   wchar_t wch;
194   unsigned chwd;
195   size_t n;
196
197   /* Determine the next character's code WCH, the length N of its encoding in
198    * P in bytes, and the character's width CHWD in columns.
199    */
200   n = mbrtowc(&wch, m->p + m->i, m->sz - m->i, &m->ps);
201   if (!n) { chwd = 0; n = m->sz - m->i; }
202   else if (n == CONV_MORE) { chwd = 2; n = m->sz - m->i; }
203   else if (n == CONV_BAD) { chwd = 2; n = 1; }
204   else chwd = wcwidth(wch);
205
206   /* Advance the state. */
207   m->i += n; m->wd += chwd;
208
209   /* Report whether there's more to come. */
210   return (m->i < m->sz);
211 }
212
213 static unsigned string_width(const char *p, size_t sz)
214         /* Return the width of the SZ-byte string P, in terminal columns. */
215 {
216   struct measure m;
217
218   init_measure(&m, p, sz);
219   while (advance_measure(&m));
220   return (m.wd);
221 }
222
223 static size_t split_string(const char *p, size_t sz,
224                            unsigned *wd_out, unsigned maxwd)
225         /* Return the size, in bytes, of the shortest prefix of the SZ-byte
226          * string P which is no less than MAXWD columns wide, or SZ if it's
227          * just too short.  Store the actual width in *WD_OUT.
228          */
229 {
230   struct measure m;
231   size_t i; unsigned wd;
232   int more;
233
234   init_measure(&m, p, sz);
235
236   /* Advance until we're past the bound. */
237   for (;;) {
238     if (!advance_measure(&m)) { *wd_out = m.wd; return (sz); }
239     if (m.wd >= maxwd) break;
240   }
241
242   /* Now /continue/ advancing past zero-width characters until we find
243    * something that wasn't zero-width.  These might be combining accents or
244    * somesuch, and leaving them off would definitely be wrong.
245    */
246   wd = m.wd; i = m.i;
247   for (;;) {
248     more = advance_measure(&m);
249     if (m.wd > wd) break;
250     i = m.i;
251     if (!more) break;
252   }
253
254   /* All done. */
255   *wd_out = wd; return (i);
256 }
257
258 #else
259
260 static unsigned string_width(const char *p, size_t sz) { return (sz); }
261
262 static size_t split_string(const char *p, size_t sz,
263                            unsigned *wd_out, unsigned maxwd)
264 {
265   unsigned wd;
266
267   if (sz <= maxwd) wd = sz;
268   else wd = maxwd;
269   *wd_out = wd; return (wd);
270 }
271
272 #endif
273
274 /*----- Output buffer handling --------------------------------------------*/
275
276 static void grow_linebuf(struct ttyprogress_render *render, size_t want)
277         /* Extend the line buffer in RENDER so that it's at least WANT bytes
278          * long.  Shuffle the accumulated left and right material in the
279          * buffer as necessary.
280          */
281 {
282   struct ttyprogress_buffer *line = render->line;
283   char *newbuf; size_t newsz;
284
285   /* Return if there's already enough space. */
286   if (want <= line->sz) return;
287
288   /* Work out how much space to allocate.  The initial size is a rough guess
289    * based on the size of UTF-8 encoded characters, though it's not an upper
290    * bound because many characters have zero width.  Double the buffer size
291    * if it's too small.  Sneakily insert a terminating zero byte just in
292    * case.
293    */
294   newsz = line->sz;
295   GROWBUF_SIZE(size_t, newsz, want, 4*render->width + 1, 1);
296   newbuf = x_alloc(line->a, newsz + 1);
297   newbuf[newsz] = 0;
298
299   /* Copy the left and right strings into the new buffer. */
300   if (render->leftsz)
301     memcpy(newbuf, line->p, render->leftsz);
302   if (render->rightsz)
303     memcpy(newbuf + newsz - render->rightsz,
304            line->p + line->sz - render->rightsz,
305            render->rightsz);
306
307   /* Free the old buffer and remember the new one. */
308   x_free(line->a, line->p); line->p = newbuf; line->sz = newsz;
309 }
310
311 enum { LEFT, RIGHT, STOP };
312
313 static int putstr(struct ttyprogress_render *render, unsigned side,
314                   const char *p, size_t n)
315         /* Add the N-byte string P to SIDE of the line buffer in RENDER.
316          * Return 0 on success or -1 if this fails for any reason.
317          */
318 {
319   unsigned newwd = string_width(p, n);
320   size_t want;
321
322   if (newwd >= render->width - render->leftwd - render->rightwd) return (-1);
323   want = render->leftsz + render->rightsz + n;
324   if (want > render->line->sz) grow_linebuf(render, want);
325   switch (side) {
326     case LEFT:
327       memcpy(render->line->p + render->leftsz, p, n);
328       render->leftsz += n; render->leftwd += newwd;
329       break;
330     case RIGHT:
331       memcpy(render->line->p + render->line->sz - render->rightsz - n, p, n);
332       render->rightsz += n; render->rightwd += newwd;
333       break;
334     default:
335       assert(0);
336   }
337   return (0);
338 }
339
340 int ttyprogress_vputleft(struct ttyprogress_render *render,
341                       const char *fmt, va_list *ap)
342 {
343   dstr *t = &render->line->t;
344
345   DRESET(t); dstr_vputf(t, fmt, ap);
346   return (putstr(render, LEFT, t->buf, t->len));
347 }
348
349 int ttyprogress_vputright(struct ttyprogress_render *render,
350                        const char *fmt, va_list *ap)
351 {
352   dstr *t = &render->line->t;
353
354   DRESET(t); dstr_vputf(t, fmt, ap);
355   return (putstr(render, RIGHT, t->buf, t->len));
356 }
357
358 int ttyprogress_putleft(struct ttyprogress_render *render,
359                         const char *fmt, ...)
360 {
361   va_list ap;
362   int rc;
363
364   va_start(ap, fmt); rc = ttyprogress_vputleft(render, fmt, &ap); va_end(ap);
365   return (rc);
366 }
367
368 int ttyprogress_putright(struct ttyprogress_render *render,
369                       const char *fmt, ...)
370 {
371   va_list ap;
372   int rc;
373
374   va_start(ap, fmt); rc = ttyprogress_vputright(render, fmt, &ap); va_end(ap);
375   return (rc);
376 }
377
378 /*----- Maintaining the progress display ----------------------------------*/
379
380 #define CLRF_ALL 1u                     /* clear everything */
381 static void clear_progress(struct ttyprogress *progress, unsigned f)
382         /* Clear the current progress display maintained by PROGRESS.
383          *
384          * If `CLRF_ALL' is set in F, then clear the entire display.
385          * Otherwise, clear the bottom few lines if there are now fewer
386          * progress items than there were last time we rendered the display,
387          * and leave the cursor at the start of the top line ready to
388          * overwrite it.
389          */
390 {
391   struct tty *tty = progress->tty;
392   int ndel, nleave;
393
394   if (progress->last_lines) {
395
396     /* Decide how many lines to delete.  Set `ndel' to the number of lines
397      * that will be entirely erased, and `nleave' to the number that we'll
398      * leave.
399      */
400     if (f&CLRF_ALL)
401       { ndel = progress->last_lines; nleave = 0; }
402     else {
403       if (progress->nitems >= progress->last_lines) ndel = 0;
404       else ndel = progress->last_lines - progress->nitems;
405       nleave = progress->last_lines - ndel;
406     }
407
408     /* Now actually do the clearing.  Remember that the cursor is still on
409      * the last line.
410      */
411     if (!ndel)
412       tty_move(tty, TTOF_YCUR | TTOF_XHOME, 1 - nleave, 0);
413     else {
414       tty_move(tty, TTOF_YCUR | TTOF_XHOME, 1 - ndel, 0);
415       tty_erase(tty, TTEF_DSP | TTEF_END);
416       tty_move(tty, TTORG_CUR, -nleave, 0);
417     }
418   }
419
420   /* Remember that we're now at the top of the display. */
421   progress->last_lines = 0;
422 }
423
424 int ttyprogress_clear(struct ttyprogress *progress)
425 {
426   if (!progress->tty) return (-1);
427   clear_progress(progress, CLRF_ALL);
428   return (0);
429 }
430
431 int ttyprogress_update(struct ttyprogress *progress)
432 {
433   struct ttyprogress_render render;
434   struct ttyprogress_item *item;
435   struct tty *tty = progress->tty;
436   struct tty_attr save;
437   uint32 modes;
438   unsigned f = 0;
439 #define f_any 1u
440
441   if (!tty) return (-1);
442
443   setup_render_state(progress, &render);
444   clear_progress(progress, 0);
445   modes = tty->st.modes; tty_setmodes(tty, TTMF_AUTOM, 0);
446   save = tty->st.attr;
447
448   for (item = progress->items; item; item = item->next) {
449     if (f&f_any) tty_move(tty, TTOF_YCUR | TTOF_XHOME, 1, 0);
450     render.leftsz = render.rightsz = 0;
451     render.leftwd = render.rightwd = 0;
452     item->render(item, &render); progress->last_lines++; f |= f_any;
453     if (progress->last_lines > tty->ht) break;
454   }
455   tty_setmodes(tty, MASK32, modes); tty_setattr(tty, &save);
456   fflush(tty->fpout);
457   return (0);
458
459 #undef f_any
460 }
461
462 /*----- Rendering progress bars -------------------------------------------*/
463
464 /* The basic problem here is to render text, formed of several pieces, to the
465  * terminal, placing some marker in the middle of it to indicate how much
466  * progress has been made.  This marker might be a colour change, switching
467  * off reverse-video mode, or a `|' character.
468  */
469
470 struct bar {
471         /* State to track progress through the output of a progress bar, so
472          * that we insert the marker in the right place.
473          *
474          * This is a little state machine.  We remember the current column
475          * position, the current state, and the column at which we'll next
476          * change state.
477          */
478
479   const struct ttyprogress_render *render; /* render state */
480   unsigned pos, nextpos, state;         /* as described */
481 };
482
483 static void advance_bar_state(struct bar *bar)
484         /* If we've reached the column position for the next state change
485          * then arrange to do whatever it is we're meant to do, and update
486          * for the next change.
487          */
488 {
489   const struct ttyprogress_render *render = bar->render;
490   struct tty *tty = render->tty;
491   size_t here = bar->nextpos;
492
493   while (bar->nextpos <= here) {
494     switch (bar->state) {
495       case LEFT:
496         if (!(tty->acaps&(TTACF_BG | TTACF_INVV))) putc('|', tty->fpout);
497         else tty_setattr(tty, &render->attr[TPHL_EBAR]);
498         bar->state = RIGHT; bar->nextpos = render->width;
499         break;
500       case RIGHT:
501         bar->state = STOP; bar->nextpos = UINT_MAX;
502         break;
503     }
504   }
505 }
506
507 /* Little utility to output a chunk of text. */
508 static void put_str(FILE *fp, const char *p, size_t sz)
509   { while (sz--) putc(*p++, fp); }
510
511 static void put_spc(struct tty *tty, unsigned n)
512 {
513   if (!(~tty->ocaps&(TTCF_RELMV | TTCF_BGER | TTCF_ERCH)))
514     { tty_erch(tty, n); tty_move(tty, TTORG_CUR, 0, n); }
515   else
516     tty_repeat(tty, ' ', n);
517 }
518
519 static void put_barstr(struct bar *bar, const char *p, size_t sz)
520         /* Output the SZ-byte string P, driving the state machine BAR as we
521          * go.
522          */
523 {
524   FILE *fp = bar->render->tty->fpout;
525   unsigned wd;
526   size_t n;
527
528   for (;;) {
529     /* Main loop.  Determine how much space there is to the next state
530      * change, cut off that amount of space from the string, and advance.
531      */
532
533     n = split_string(p, sz, &wd, bar->nextpos - bar->pos);
534     if (n == sz && wd < bar->nextpos - bar->pos) break;
535     put_str(fp, p, n); bar->pos += wd; advance_bar_state(bar);
536     p += n; sz -= n;
537   }
538
539   /* Write out the rest of the string, and update the position.  We know that
540    * this won't reach the next transition.
541    */
542   put_str(fp, p, sz); bar->pos += wd;
543 }
544
545 static void put_barspc(struct bar *bar, unsigned n)
546         /* Output N spaces, driving the state machine BAR as we go. */
547 {
548   struct tty *tty = bar->render->tty;
549   unsigned step;
550
551   for (;;) {
552     step = bar->nextpos - bar->pos;
553     if (n < step) break;
554     put_spc(tty, step); bar->pos += step; n -= step;
555     advance_bar_state(bar);
556   }
557   put_spc(tty, n); bar->pos += n;
558 }
559
560 int ttyprogress_showbar(struct ttyprogress_render *render, double frac)
561 {
562   struct tty *tty = render->tty;
563   const struct ttyprogress_buffer *line = render->line;
564   struct bar bar;
565
566   /* If there's no terminal, then there's nothing to do. */
567   if (!tty) return (-1);
568
569   /* Clamp the fraction.  Rounding errors in the caller's calculation might
570    * legitimately leave it slightly out of bounds.  The sense of the
571    * comparisons also catches QNaNs, which are silently squashed to zero so
572    * as to prevent anything untoward happening when we convert to integer
573    * arithmetic below.
574    */
575   frac = !(frac >= 0.0) ? 0.0 : !(frac <= 1.0) ? 1.0 : frac;
576
577   /* Set up the render state, with a transition where the bar should end. */
578   bar.render = render; bar.pos = 0; bar.nextpos = frac*render->width + 0.5;
579
580   /* Set the initial state for the render. */
581   if (tty->acaps&(TTACF_BG | TTACF_INVV)) {
582     /* We have highlighting.  If we have made only negligible progress then
583      * advance the state machine immediately, which will set the correct
584      * highlighting; otherwise, set the beginning-of-bar highlight.
585      */
586
587     bar.state = LEFT;
588     if (bar.nextpos) tty_setattr(tty, &render->attr[TPHL_BBAR]);
589     else advance_bar_state(&bar);
590   } else {
591     /* Nothing fancy.  We'll write `|' at the right place. */
592
593     bar.state = LEFT;
594   }
595
596   /* Write the left string, spaces to fill the gap, and the right string. */
597   put_barstr(&bar, line->p, render->leftsz);
598   put_barspc(&bar, render->width - render->leftwd - render->rightwd);
599   put_barstr(&bar, line->p + line->sz - render->rightsz, render->rightsz);
600
601   /* All done. */
602   return (0);
603 }
604
605 int ttyprogress_shownotice(struct ttyprogress_render *render,
606                            const struct tty_attr *attr)
607 {
608   struct tty *tty = render->tty;
609   const struct ttyprogress_buffer *line = render->line;
610
611   /* If there's no terminal, then there's nothing to do. */
612   if (!tty->fpout) return (-1);
613
614   /* Set the attributes. */
615   tty_setattr(tty, attr);
616
617   /* Print the left and right strings. */
618   put_str(tty->fpout, line->p, render->leftsz);
619   put_spc(tty, render->width - render->leftwd - render->rightwd);
620   put_str(tty->fpout, line->p + line->sz - render->rightsz, render->rightsz);
621
622   /* All done. */
623   return (0);
624 }
625
626 /*----- That's all, folks -------------------------------------------------*/