From b205dd10bcaf7cef4119b6a47f62de262e527b00 Mon Sep 17 00:00:00 2001 From: Martijn Pieters Date: Thu, 24 Aug 2023 18:29:17 +0100 Subject: [PATCH] Reposition bars below a closed bar When a bar is closed it is moved to pos 0, which means all the other bars below it must now be given a new position number to have their output still go to the same screen line. --- tests/tests_tqdm.py | 69 ++++++++++++++++++++++++++++++++++---- tqdm/std.py | 81 +++++++++++++++++++++++++++++---------------- 2 files changed, 116 insertions(+), 34 deletions(-) diff --git a/tests/tests_tqdm.py b/tests/tests_tqdm.py index 78c3fb712..0aedd1cdd 100644 --- a/tests/tests_tqdm.py +++ b/tests/tests_tqdm.py @@ -1281,20 +1281,78 @@ def test_position(): t3.update(1) t4.update(1) res = [m[0] for m in RE_pos.findall(our_file.getvalue())] - exres = ['\r1.pos0 bar: 0%', - '\n\r2.pos1 bar: 0%', - '\n\n\r3.pos2 bar: 0%', + exres = [*exres, '\r2.pos1 bar: 0%', + '\n\n\r3.pos2 bar: 0%', '\n\n\r4.pos2 bar: 0%', '\r1.pos0 bar: 10%', - '\n\n\r3.pos2 bar: 10%', - '\n\r4.pos2 bar: 10%'] + '\n\r3.pos2 bar: 10%', + '\n\n\r4.pos2 bar: 10%'] pos_line_diff(res, exres) t4.close() t3.close() t1.close() +@mark.skipif(nt_and_no_colorama, reason="Windows without colorama") +@mark.parametrize('leave', [ + True, # all bars remain + False, # no bars remain + None # only first bar remains +]) +def test_position_leave(leave: bool): + """Test leaving of nested positioned progress bars""" + our_file = StringIO() + kwargs = { + 'file': our_file, + 'miniters': 1, + 'mininterval': 0, + 'maxinterval': 0, + 'leave': leave, + } + for _ in trange(2, desc='pos0 bar', position=0, **kwargs): + t2 = tqdm(total=2, desc='pos1 bar', position=1, **kwargs) + t2.update() + t3 = tqdm(total=2, desc='pos2 bar', position=2, **kwargs) + t3.update() + # complete t2 before t3 + t2.update() + t2.close() + t3.update() + t3.close() + + out = our_file.getvalue() + res = [m[0] for m in RE_pos.findall(out)] + # Bar 2 being left from the screen means bar 3 needs extra newline when + # positioning. If it is not left, then bar 3 needs to be cleared in its old + # position and redrawn in gap left by bar 2. + if leave: + bar2left, bar3move = '\n', [] + else: + bar2left, bar3move = '', ['\n\n\r ', '\r\x1b[A\x1b[A'] + innerex = ['\n\rpos1 bar: 0%', + '\n\rpos1 bar: 50%', + '\n\n\rpos2 bar: 0%', + '\n\n\rpos2 bar: 50%', + '\n\rpos1 bar: 100%', + '\rpos1 bar: 100%' if leave else '\n\r ', + *bar3move, + bar2left + '\n\rpos2 bar: 50%', + '\n\rpos2 bar: 100%', + '\rpos2 bar: 100%' if leave else '\n\r '] + # Bar 1 being left on screen adds an extra newline to the output + # that then shows up as part of the next res line. + bar1left = '\n' if leave else '' + exres = ['\rpos0 bar: 0%', + *innerex, + bar1left + '\rpos0 bar: 50%', + *innerex, + bar1left + '\rpos0 bar: 100%', + '\rpos0 bar: 100%' if leave is not False else '\r ', + '\n' if leave is not False else '\r'] + pos_line_diff(res, exres) + + def test_set_description(): """Test set description""" with closing(StringIO()) as our_file: @@ -1895,7 +1953,6 @@ def test_screen_shape(): assert "one" in res assert "two" in res assert "three" in res - assert "\n\n" not in res assert "more hidden" in res # double-check ncols assert all(len(i) == 50 for i in get_bar(res) diff --git a/tqdm/std.py b/tqdm/std.py index 9ba8e8506..fa9dd6aa9 100644 --- a/tqdm/std.py +++ b/tqdm/std.py @@ -715,6 +715,27 @@ def _decr_instances(cls, instance): inst = min(instances, key=lambda i: i.pos) inst.clear(nolock=True) inst.pos = abs(instance.pos) + else: + # renumber remaining bars with positions below this bar so + # they maintain their positions + apos = abs(instance.pos) + readjust = [ + (inst.pos, inst) + for inst in cls._instances + if not inst.disable and abs(getattr(inst, "pos", apos)) > apos + ] + for pos, inst in sorted(readjust, key=lambda pi: -abs(pi[0])): + newpos = inst.pos + (1 if pos < 0 else -1) + if newpos == 0 and inst.leave is None: + # any bars now moving to pos=0 should not be left on + # screen if `leave` was set to `None`. + inst.leave = False + if not inst.leave: + # Clear the old position before moving the bar so we + # don't leave any artefacts on screen. + inst.clear(nolock=True) + inst.pos = newpos + inst.display() @classmethod def write(cls, s, file=None, end="\n", nolock=False): @@ -1271,41 +1292,45 @@ def close(self): # Prevent multiple closures self.disable = True - # decrement instance pos and remove from internal set + # decrement instance pos pos = abs(self.pos) - self._decr_instances(self) - if self.last_print_t < self.start_t + self.delay: - # haven't ever displayed; nothing to clear - return + try: + if self.last_print_t < self.start_t + self.delay: + # haven't ever displayed; nothing to clear + return - # GUI mode - if getattr(self, 'sp', None) is None: - return + # GUI mode + if getattr(self, 'sp', None) is None: + return - # annoyingly, _supports_unicode isn't good enough - def fp_write(s): - self.fp.write(str(s)) + # annoyingly, _supports_unicode isn't good enough + def fp_write(s): + self.fp.write(str(s)) - try: - fp_write('') - except ValueError as e: - if 'closed' in str(e): - return - raise # pragma: no cover + try: + fp_write('') + except ValueError as e: + if 'closed' in str(e): + return + raise # pragma: no cover - leave = pos == 0 if self.leave is None else self.leave + leave = pos == 0 if self.leave is None else self.leave - with self._lock: - if leave: - # stats for overall rate (no weighted average) - self._ema_dt = lambda: None - self.display(pos=0) - fp_write('\n') - else: - # clear previous display - if self.display(msg='', pos=pos) and not pos: - fp_write('\r') + with self._lock: + if leave: + # stats for overall rate (no weighted average) + self._ema_dt = lambda: None + self.display(pos=0) + fp_write('\n') + else: + # clear previous display + if self.display(msg='', pos=pos) and not pos: + fp_write('\r') + + finally: + # Remove from internal set + self._decr_instances(self) def clear(self, nolock=False): """Clear current bar display."""