chiark / gitweb /
@@@ tty cleanup
[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_state save;
437   unsigned f = 0;
438 #define f_any 1u
439
440   if (!tty) return (-1);
441
442   setup_render_state(progress, &render);
443   clear_progress(progress, 0);
444   save = tty->st;
445
446   for (item = progress->items; item; item = item->next) {
447     if (f&f_any) tty_move(tty, TTOF_YCUR | TTOF_XHOME, 1, 0);
448     render.leftsz = render.rightsz = 0;
449     render.leftwd = render.rightwd = 0;
450     item->render(item, &render); progress->last_lines++; f |= f_any;
451     if (progress->last_lines > tty->ht) break;
452   }
453   tty_restore(tty, &save);
454   fflush(tty->fpout);
455   return (0);
456
457 #undef f_any
458 }
459
460 /*----- Rendering progress bars -------------------------------------------*/
461
462 /* The basic problem here is to render text, formed of several pieces, to the
463  * terminal, placing some marker in the middle of it to indicate how much
464  * progress has been made.  This marker might be a colour change, switching
465  * off reverse-video mode, or a `|' character.
466  */
467
468 struct bar {
469         /* State to track progress through the output of a progress bar, so
470          * that we insert the marker in the right place.
471          *
472          * This is a little state machine.  We remember the current column
473          * position, the current state, and the column at which we'll next
474          * change state.
475          */
476
477   const struct ttyprogress_render *render; /* render state */
478   unsigned pos, nextpos, state;         /* as described */
479 };
480
481 static void advance_bar_state(struct bar *bar)
482         /* If we've reached the column position for the next state change
483          * then arrange to do whatever it is we're meant to do, and update
484          * for the next change.
485          */
486 {
487   const struct ttyprogress_render *render = bar->render;
488   struct tty *tty = render->tty;
489   size_t here = bar->nextpos;
490
491   while (bar->nextpos <= here) {
492     switch (bar->state) {
493       case LEFT:
494         if (!(tty->acaps&(TTACF_BG | TTACF_INVV))) putc('|', tty->fpout);
495         else tty_setattr(tty, &render->attr[TPHL_EBAR]);
496         bar->state = RIGHT; bar->nextpos = render->width;
497         break;
498       case RIGHT:
499         bar->state = STOP; bar->nextpos = UINT_MAX;
500         break;
501     }
502   }
503 }
504
505 /* Little utility to output a chunk of text. */
506 static void put_str(FILE *fp, const char *p, size_t sz)
507   { while (sz--) putc(*p++, fp); }
508
509 static void put_spc(struct tty *tty, unsigned n)
510 {
511   if (!(~tty->ocaps&(TTCF_RELMV | TTCF_BGER | TTCF_ERCH)))
512     { tty_erch(tty, n); tty_move(tty, TTORG_CUR, 0, n); }
513   else
514     tty_repeat(tty, ' ', n);
515 }
516
517 static void put_barstr(struct bar *bar, const char *p, size_t sz)
518         /* Output the SZ-byte string P, driving the state machine BAR as we
519          * go.
520          */
521 {
522   FILE *fp = bar->render->tty->fpout;
523   unsigned wd;
524   size_t n;
525
526   for (;;) {
527     /* Main loop.  Determine how much space there is to the next state
528      * change, cut off that amount of space from the string, and advance.
529      */
530
531     n = split_string(p, sz, &wd, bar->nextpos - bar->pos);
532     if (n == sz && wd < bar->nextpos - bar->pos) break;
533     put_str(fp, p, n); bar->pos += wd; advance_bar_state(bar);
534     p += n; sz -= n;
535   }
536
537   /* Write out the rest of the string, and update the position.  We know that
538    * this won't reach the next transition.
539    */
540   put_str(fp, p, sz); bar->pos += wd;
541 }
542
543 static void put_barspc(struct bar *bar, unsigned n)
544         /* Output N spaces, driving the state machine BAR as we go. */
545 {
546   struct tty *tty = bar->render->tty;
547   unsigned step;
548
549   for (;;) {
550     step = bar->nextpos - bar->pos;
551     if (n < step) break;
552     put_spc(tty, step); bar->pos += step; n -= step;
553     advance_bar_state(bar);
554   }
555   put_spc(tty, n); bar->pos += n;
556 }
557
558 int ttyprogress_showbar(struct ttyprogress_render *render, double frac)
559 {
560   struct tty *tty = render->tty;
561   const struct ttyprogress_buffer *line = render->line;
562   struct bar bar;
563
564   /* If there's no terminal, then there's nothing to do. */
565   if (!tty) return (-1);
566
567   /* Clamp the fraction.  Rounding errors in the caller's calculation might
568    * legitimately leave it slightly out of bounds.  The sense of the
569    * comparisons also catches QNaNs, which are silently squashed to zero so
570    * as to prevent anything untoward happening when we convert to integer
571    * arithmetic below.
572    */
573   frac = !(frac >= 0.0) ? 0.0 : !(frac <= 1.0) ? 1.0 : frac;
574
575   /* Set up the render state, with a transition where the bar should end. */
576   bar.render = render; bar.pos = 0; bar.nextpos = frac*render->width + 0.5;
577
578   /* Set the initial state for the render. */
579   if (tty->acaps&(TTACF_BG | TTACF_INVV)) {
580     /* We have highlighting.  If we have made only negligible progress then
581      * advance the state machine immediately, which will set the correct
582      * highlighting; otherwise, set the beginning-of-bar highlight.
583      */
584
585     bar.state = LEFT;
586     if (bar.nextpos) tty_setattr(tty, &render->attr[TPHL_BBAR]);
587     else advance_bar_state(&bar);
588   } else {
589     /* Nothing fancy.  We'll write `|' at the right place. */
590
591     bar.state = LEFT;
592   }
593
594   /* Write the left string, spaces to fill the gap, and the right string. */
595   put_barstr(&bar, line->p, render->leftsz);
596   put_barspc(&bar, render->width - render->leftwd - render->rightwd);
597   put_barstr(&bar, line->p + line->sz - render->rightsz, render->rightsz);
598
599   /* All done. */
600   return (0);
601 }
602
603 int ttyprogress_shownotice(struct ttyprogress_render *render,
604                            const struct tty_attr *attr)
605 {
606   struct tty *tty = render->tty;
607   const struct ttyprogress_buffer *line = render->line;
608
609   /* If there's no terminal, then there's nothing to do. */
610   if (!tty->fpout) return (-1);
611
612   /* Set the attributes. */
613   tty_setattr(tty, attr);
614
615   /* Print the left and right strings. */
616   put_str(tty->fpout, line->p, render->leftsz);
617   put_spc(tty, render->width - render->leftwd - render->rightwd);
618   put_str(tty->fpout, line->p + line->sz - render->rightsz, render->rightsz);
619
620   /* All done. */
621   return (0);
622 }
623
624 /*----- That's all, folks -------------------------------------------------*/