1 /*
2  * Copyright (C) 2020, 2021  Vladimir Panteleev <btdu@cy.md>
3  *
4  * This program is free software; you can redistribute it and/or
5  * modify it under the terms of the GNU General Public
6  * License v2 as published by the Free Software Foundation.
7  *
8  * This program is distributed in the hope that it will be useful,
9  * but WITHOUT ANY WARRANTY; without even the implied warranty of
10  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
11  * General Public License for more details.
12  *
13  * You should have received a copy of the GNU General Public
14  * License along with this program; if not, write to the
15  * Free Software Foundation, Inc., 59 Temple Place - Suite 330,
16  * Boston, MA 021110-1307, USA.
17  */
18 
19 /// ncurses interface for browsing results
20 module btdu.browser;
21 
22 import core.stdc.config;
23 import core.stdc.locale;
24 import core.stdc.stddef : wchar_t;
25 import core.time;
26 
27 import std.algorithm.comparison;
28 import std.algorithm.iteration;
29 import std.algorithm.mutation;
30 import std.algorithm.searching;
31 import std.algorithm.sorting;
32 import std.conv;
33 import std.encoding : sanitize;
34 import std.format;
35 import std.range;
36 import std..string;
37 
38 import deimos.ncurses;
39 
40 import ae.utils.appender;
41 import ae.utils.meta;
42 import ae.utils.text;
43 import ae.utils.time : stdDur, stdTime;
44 
45 import btdu.common;
46 import btdu.state;
47 import btdu.paths;
48 
49 struct Browser
50 {
51 	BrowserPath* currentPath;
52 	sizediff_t top; // Scroll offset (row number, in the content, corresponding to the topmost displayed line)
53 	sizediff_t contentAreaHeight; // Number of rows where scrolling content is displayed
54 	BrowserPath* selection;
55 	BrowserPath*[] items;
56 	string[] textLines;
57 	bool done;
58 
59 	enum Mode
60 	{
61 		browser,
62 		info,
63 		help,
64 	}
65 	Mode mode;
66 
67 	enum SortMode
68 	{
69 		name,
70 		size,
71 	}
72 	SortMode sortMode;
73 	bool reverseSort, dirsFirst;
74 
75 	enum RatioDisplayMode
76 	{
77 		none,
78 		graph,
79 		percentage,
80 		both,
81 	}
82 	RatioDisplayMode ratioDisplayMode = RatioDisplayMode.graph;
83 
84 	void start()
85 	{
86 		setlocale(LC_CTYPE, "");
87 		initscr();
88 
89 		timeout(0); // Use non-blocking read
90 		cbreak(); // Disable line buffering
91 		noecho(); // Disable keyboard echo
92 		keypad(stdscr, true); // Enable arrow keys
93 		curs_set(0); // Hide cursor
94 
95 		currentPath = &browserRoot;
96 	}
97 
98 	~this()
99 	{
100 		endwin();
101 	}
102 
103 	@disable this(this);
104 
105 	string message; MonoTime showMessageUntil;
106 	void showMessage(string s)
107 	{
108 		message = s;
109 		showMessageUntil = MonoTime.currTime() + (100.msecs * s.length);
110 	}
111 
112 	private static FastAppender!char buf; // Reusable buffer
113 
114 	void update()
115 	{
116 		int h, w;
117 		getmaxyx(stdscr, h, w); h++; w++;
118 
119 		items = null;
120 		for (auto child = currentPath.firstChild; child; child = child.nextSibling)
121 			items ~= child;
122 
123 		final switch (sortMode)
124 		{
125 			case SortMode.name:
126 				items.sort!((a, b) => a.name[] < b.name[]);
127 				break;
128 			case SortMode.size:
129 				items.multiSort!(
130 					(a, b) => a.data[SampleType.represented].samples > b.data[SampleType.represented].samples,
131 					(a, b) => a.name[] < b.name[],
132 				);
133 				break;
134 		}
135 		if (reverseSort)
136 			items.reverse();
137 		if (dirsFirst)
138 			items.sort!(
139 				(a, b) => !!a.firstChild > !!b.firstChild,
140 				SwapStrategy.stable,
141 			);
142 
143 		if (!selection && items.length)
144 			selection = items[0];
145 		if (!items.length && mode == Mode.browser && currentPath !is &browserRoot)
146 			mode = Mode.info;
147 
148 		auto totalSamples = browserRoot.data[SampleType.represented].samples;
149 
150 		// Build info
151 		final switch (mode)
152 		{
153 			case Mode.browser:
154 			case Mode.info:
155 			{
156 				string[][] info;
157 
158 				char[] fullPath;
159 				{
160 					buf.clear();
161 					buf.put(fsPath);
162 					bool recurse(BrowserPath *path)
163 					{
164 						string name = path.name[];
165 						if (name.skipOverNul())
166 							switch (name)
167 							{
168 								case "DATA":
169 								case "UNREACHABLE":
170 									return true;
171 								default:
172 									return false;
173 							}
174 						if (path.parent)
175 							if (!recurse(path.parent))
176 								return false;
177 						buf.put('/');
178 						buf.put(name);
179 						return true;
180 					}
181 					if (recurse(currentPath))
182 						fullPath = buf.get();
183 				}
184 
185 				string[] showSampleType(SampleType type, string name, bool showError)
186 				{
187 					return [
188 						"- " ~ name ~ ": " ~ (totalSamples
189 							? format!"~%s (%d sample%s)%s"(
190 								humanSize(currentPath.data[type].samples * real(totalSize) / totalSamples),
191 								currentPath.data[type].samples,
192 								currentPath.data[type].samples == 1 ? "" : "s",
193 								showError ? ", ±" ~ humanSize(estimateError(totalSamples, currentPath.data[type].samples) * totalSize) : "",
194 							)
195 							: "-"),
196 
197 						// "  - Average query duration: " ~ (currentPath.data[type].samples
198 						// 	? stdDur(currentPath.data[type].duration / currentPath.data[type].samples).toString()
199 						// 	: "-"),
200 
201 						(expert ? "  " : "") ~ "- Logical offsets: " ~ (currentPath.data[type].samples
202 							? format!"%s%(%d, %)"(
203 								currentPath.data[type].samples > currentPath.data[type].logicalOffsets.length ? "..., " : "",
204 								currentPath.data[type].logicalOffsets[].filter!(o => o != ulong.max),
205 							)
206 							: "-"),
207 					];
208 				}
209 
210 				info ~= chain(
211 					["--- Details: "],
212 
213 					fullPath ? ["- Full path: " ~ cast(string)fullPath] : [],
214 
215 					(){
216 						string[] result;
217 						if (currentPath.parent && currentPath.parent.parent && currentPath.parent.parent.name[] == "\0ERROR")
218 						{
219 							auto errno = currentPath.name[] in errnoLookup;
220 							if (errno)
221 							{
222 								result ~= "- Error code: " ~ text(*errno);
223 								auto description = getErrno(*errno).description;
224 								if (description)
225 									result ~= "- Error message: " ~ description;
226 							}
227 						}
228 						return result;
229 					}(),
230 
231 					["- Average query duration: " ~ (currentPath.data[SampleType.represented].samples
232 							? stdDur(currentPath.data[SampleType.represented].duration / currentPath.data[SampleType.represented].samples).toDecimalString()
233 							: "-")],
234 				).array;
235 
236 				if (expert)
237 				{
238 					info ~= showSampleType(SampleType.represented, "Represented size", true);
239 					info ~= ["- Distributed size: " ~ (totalSamples
240 						? format!"~%s (%1.3f sample%s)"(
241 							humanSize(currentPath.distributedSamples * real(totalSize) / totalSamples),
242 							currentPath.distributedSamples,
243 							currentPath.distributedSamples == 1 ? "" : "s",
244 						)
245 						: "-")];
246 
247 					info ~= showSampleType(SampleType.exclusive, "Exclusive size", true);
248 					info ~= showSampleType(SampleType.shared_, "Shared size", false);
249 				}
250 				else
251 				{
252 					info[$-1] ~= showSampleType(SampleType.represented, "Represented size", true);
253 				}
254 
255 				{
256 					string explanation = {
257 						if (currentPath is &browserRoot)
258 							return
259 								"Welcome to btdu. You are in the hierarchy root; " ~
260 								"results will be arranged according to their block group and profile, and then by path." ~
261 								"\n\n" ~
262 								"Use the arrow keys to navigate, press ? for help.";
263 
264 						string name = currentPath.name[];
265 						if (name.skipOverNul())
266 						{
267 							switch (name)
268 							{
269 								case "DATA":
270 									return
271 										"This node holds samples from chunks in the DATA block group, " ~
272 										"which mostly contains file data.";
273 								case "METADATA":
274 									return
275 										"This node holds samples from chunks in the METADATA block group, " ~
276 										"which contains btrfs internal metadata arranged in b-trees." ~
277 										"\n\n" ~
278 										"The contents of small files may be stored here, in line with their metadata." ~
279 										"\n\n" ~
280 										"The contents of METADATA chunks is opaque to btdu, so this node does not have children.";
281 								case "SYSTEM":
282 									return
283 										"This node holds samples from chunks in the SYSTEM block group " ~
284 										"which contains some core btrfs information, such as how to map physical device space to linear logical space or vice-versa." ~
285 										"\n\n" ~
286 										"The contents of SYSTEM chunks is opaque to btdu, so this node does not have children.";
287 								case "SINGLE":
288 								case "RAID0":
289 								case "RAID1":
290 								case "DUP":
291 								case "RAID10":
292 								case "RAID5":
293 								case "RAID6":
294 								case "RAID1C3":
295 								case "RAID1C4":
296 									return
297 										"This node holds samples from chunks in the " ~ name ~ " profile.";
298 								case "ERROR":
299 									return
300 										"This node represents sample points for which btdu encountered an error when attempting to query them." ~
301 										"\n\n" ~
302 										"Children of this node indicate the encountered error, and may have a more detailed explanation attached.";
303 								case "ROOT_TREE":
304 									return
305 										"This node holds samples with inodes contained in the BTRFS_ROOT_TREE_OBJECTID object." ~
306 										"\n\n" ~
307 										"These samples are not resolvable to paths, and most likely indicate some kind of metadata. " ~
308 										"(If you know, please tell me!)";
309 								case "NO_INODE":
310 									return
311 										"This node represents sample points for which btrfs successfully completed our request " ~
312 										"to look up inodes at the given logical offset, but did not actually return any inodes." ~
313 										"\n\n" ~
314 										"One possible cause is data which was deleted recently.";
315 								case "NO_PATH":
316 									return
317 										"This node represents sample points for which btrfs successfully completed our request " ~
318 										"to look up filesystem paths for the given inode, but did not actually return any paths.";
319 								case "UNREACHABLE":
320 									return
321 										"This node represents sample points in extents which are not used by any files.\n" ~
322 										"Despite not being directly used, these blocks are kept because another part of the extent they belong to is actually used by files." ~
323 										"\n\n" ~
324 										"This can happen if a large file is written in one go, and then later one block is overwritten - " ~
325 										"btrfs may keep the old extent which still contains the old copy of the overwritten block." ~
326 										"\n\n" ~
327 										"Children of this node indicate the path of files using the extent containing the unreachable samples. " ~
328 										"Defragmentation of these files may reduce the amount of such unreachable blocks.";
329 								default:
330 									if (name.skipOver("TREE_"))
331 										return
332 											"This node holds samples with inodes contained in the tree #" ~ name ~ ", " ~
333 											"but btdu failed to resolve this tree number to an absolute path." ~
334 											"\n\n" ~
335 											"One possible cause is subvolumes which were deleted recently." ~
336 											"\n\n" ~
337 											"Another possible cause is \"ghost subvolumes\", a form of corruption which causes some orphan subvolumes to not get cleaned up.";
338 									debug assert(false, "Unknown special node: " ~ name);
339 							}
340 						}
341 
342 						if (currentPath.parent && currentPath.parent.name[] == "\0ERROR")
343 						{
344 							switch (name)
345 							{
346 								case "Unresolvable root":
347 									return
348 										"btdu failed to resolve this tree number to an absolute path.";
349 								case "logical ino":
350 									return
351 										"An error occurred while trying to look up which inodes use a particular logical offset." ~
352 										"\n\n" ~
353 										"Children of this node indicate the encountered error code, and may have a more detailed explanation attached.";
354 								case "open":
355 									return
356 										"btdu failed to open the filesystem root containing an inode." ~
357 										"\n\n" ~
358 										"Children of this node indicate the encountered error code, and may have a more detailed explanation attached.";
359 								default:
360 							}
361 						}
362 
363 						if (currentPath.parent && currentPath.parent.parent && currentPath.parent.parent.name[] == "\0ERROR")
364 						{
365 							switch (currentPath.parent.name[])
366 							{
367 								case "logical ino":
368 									switch (name)
369 									{
370 										case "ENOENT":
371 											return
372 												"btrfs reports that there is nothing at the random sample location that btdu picked." ~
373 												"\n\n" ~
374 												"This most likely represents allocated but unused space, " ~
375 												"which could be reduced by running a balance on the DATA block group.";
376 										case "ENOTTY":
377 											return
378 												"An ENOTTY (\"Inappropriate ioctl for device\") error means that btdu issued an ioctl which the kernel btrfs code does not understand." ~
379 												"\n\n" ~
380 												"The most likely cause is that you are running an old kernel version. " ~
381 												"If you update your kernel, btdu might be able to show more information instead of this error.";
382 										default:
383 									}
384 									break;
385 								case "open":
386 									switch (name)
387 									{
388 										case "ENOENT":
389 											return
390 												"btdu failed to open the filesystem root containing an inode." ~
391 												"\n\n" ~
392 												"The most likely reason for this is that you didn't specify the path to the volume root when starting btdu, " ~
393 												"and instead specified the path to a subvolume or subdirectory." ~
394 												"\n\n" ~
395 												"You can descend into this node to see the path that btdu failed to open.";
396 										default:
397 									}
398 									break;
399 								default:
400 							}
401 						}
402 
403 						return null;
404 					}();
405 
406 					if (explanation)
407 						info ~= ["--- Explanation: "] ~ explanation.verbatimWrap(w).replace("\n ", "\n").strip().split("\n");
408 				}
409 
410 				bool showSeenAs;
411 				if (currentPath.seenAs.empty)
412 					showSeenAs = false;
413 				else
414 				if (fullPath is null && currentPath.seenAs.length == 1)
415 					showSeenAs = false; // Not a real file
416 				else
417 					showSeenAs = true;
418 
419 				if (showSeenAs)
420 				{
421 					auto representedSamples = currentPath.data[SampleType.represented].samples;
422 					info ~= ["--- Shares data with: "] ~
423 						currentPath.seenAs
424 						.byKeyValue
425 						.array
426 						.sort!((ref a, ref b) => a.key < b.key)
427 						.map!(pair => format("- %s (%d%%)", pair.key, pair.value * 100 / representedSamples))
428 						.array;
429 				}
430 
431 				textLines = info.join([""]);
432 				if (mode == Mode.info)
433 				{
434 					if (!textLines.length)
435 					{
436 						if (items.length)
437 							textLines = ["  (no info for this node - press i or q to exit)"];
438 						else
439 							textLines = ["  (empty node)"];
440 					}
441 					textLines = [""] ~ textLines;
442 				}
443 				break;
444 			}
445 			case Mode.help:
446 				textLines = help.dup;
447 				break;
448 		}
449 
450 		// Hard-wrap
451 		for (size_t i = 0; i < textLines.length; i++)
452 			if (textLines[i].length > w)
453 				textLines = textLines[0 .. i] ~ textLines[i][0 .. w] ~ textLines[i][w .. $] ~ textLines[i + 1 .. $];
454 
455 		// Scrolling and cursor upkeep
456 		{
457 			contentAreaHeight = h - 3;
458 			size_t contentHeight;
459 			final switch (mode)
460 			{
461 				case Mode.browser:
462 					contentHeight = items.length;
463 					contentAreaHeight -= min(textLines.length, contentAreaHeight / 2);
464 					contentAreaHeight = min(contentAreaHeight, contentHeight + 1);
465 					break;
466 				case Mode.info:
467 				case Mode.help:
468 					contentHeight = textLines.length;
469 					break;
470 			}
471 
472 			// Ensure there is no unnecessary space at the bottom
473 			if (top + contentAreaHeight > contentHeight)
474 				top = contentHeight - contentAreaHeight;
475 			// Ensure we are never scrolled "above" the first row
476 			if (top < 0)
477 				top = 0;
478 
479 			final switch (mode)
480 			{
481 				case Mode.browser:
482 				{
483 					// Ensure the selected item is visible
484 					auto pos = selection && items ? items.countUntil(selection) : 0;
485 					top = top.clamp(
486 						pos - contentAreaHeight + 1,
487 						pos,
488 					);
489 					break;
490 				}
491 				case Mode.info:
492 				case Mode.help:
493 					break;
494 			}
495 		}
496 
497 		// Rendering
498 		sizediff_t minWidth;
499 		{
500 			erase();
501 
502 			minWidth =
503 				"  100.0 KiB ".length +
504 				[
505 					""                    .length,
506 					"[##########] "       .length,
507 					"[100.0%] "           .length,
508 					"[100.0% ##########] ".length,
509 				][ratioDisplayMode] +
510 				"/".length +
511 				6;
512 
513 			if (h < 10 || w < minWidth)
514 			{
515 				mvprintw(0, 0, "Window too small");
516 				refresh();
517 				return;
518 			}
519 
520 			attron(A_REVERSE);
521 			mvhline(0, 0, ' ', w);
522 			mvprintw(0, 0, " btdu v" ~ btduVersion ~ " @ %.*s", fsPath.length, fsPath.ptr);
523 			if (paused)
524 				mvprintw(0, w - 10, " [PAUSED] ");
525 
526 			mvhline(h - 1, 0, ' ', w);
527 			if (message && MonoTime.currTime < showMessageUntil)
528 				mvprintw(h - 1, 0, " %.*s", message.length, message.ptr);
529 			else
530 			{
531 				auto resolution = totalSamples
532 					? "~" ~ (totalSize / totalSamples).humanSize()
533 					: "-";
534 				mvprintw(h - 1, 0,
535 					" Samples: %lld  Resolution: %.*s",
536 					cast(cpp_longlong)totalSamples,
537 					resolution.length, resolution.ptr,
538 				);
539 			}
540 			attroff(A_REVERSE);
541 
542 			string prefix = "";
543 			final switch (mode)
544 			{
545 				case Mode.info:
546 					prefix = "INFO: ";
547 					goto case;
548 				case Mode.browser:
549 					auto displayedPath = currentPath is &browserRoot ? "/" : currentPath.pointerWriter.text;
550 					auto maxPathWidth = w - 8 - prefix.length;
551 					if (displayedPath.length > maxPathWidth)
552 						displayedPath = "..." ~ displayedPath[$ - (maxPathWidth - 3) .. $];
553 
554 					mvhline(1, 0, '-', w);
555 					mvprintw(1, 3,
556 						" %s%.*s ",
557 						prefix.ptr,
558 						displayedPath.length, displayedPath.ptr,
559 					);
560 					break;
561 				case Mode.help:
562 					break;
563 			}
564 		}
565 
566 		final switch (mode)
567 		{
568 			case Mode.browser:
569 			{
570 				auto mostSamples = items.fold!((a, b) => max(a, b.data[SampleType.represented].samples))(0UL);
571 
572 				foreach (i, child; items)
573 				{
574 					auto y = cast(int)(i - top);
575 					if (y < 0 || y >= contentAreaHeight)
576 						continue;
577 					y += 2;
578 
579 					if (child is selection)
580 						attron(A_REVERSE);
581 					else
582 						attroff(A_REVERSE);
583 					mvhline(y, 0, ' ', w);
584 
585 					buf.clear();
586 					{
587 						auto size = totalSamples
588 							? "~" ~ humanSize(child.data[SampleType.represented].samples * real(totalSize) / totalSamples)
589 							: "?";
590 						buf.formattedWrite!"%12s "(size);
591 					}
592 
593 					if (ratioDisplayMode)
594 					{
595 						buf.put('[');
596 						if (ratioDisplayMode & RatioDisplayMode.percentage)
597 						{
598 							if (currentPath.data[SampleType.represented].samples)
599 								buf.formattedWrite!"%5.1f%%"(100.0 * child.data[SampleType.represented].samples / currentPath.data[SampleType.represented].samples);
600 							else
601 								buf.put("    -%");
602 						}
603 						if (ratioDisplayMode == RatioDisplayMode.both)
604 							buf.put(' ');
605 						if (ratioDisplayMode & RatioDisplayMode.graph)
606 						{
607 							char[10] bar;
608 							if (mostSamples)
609 							{
610 								auto barPos = 10 * child.data[SampleType.represented].samples / mostSamples;
611 								bar[0 .. barPos] = '#';
612 								bar[barPos .. $] = ' ';
613 							}
614 							else
615 								bar[] = '-';
616 							buf.put(bar[]);
617 						}
618 						buf.put("] ");
619 					}
620 					buf.put(child.firstChild is null ? ' ' : '/');
621 
622 					{
623 						auto displayedItem = child.humanName;
624 						if (child.name[].startsWith("\0"))
625 							displayedItem = "<" ~ displayedItem ~ ">";
626 						auto maxItemWidth = w - (minWidth - 5);
627 						if (displayedItem.length > maxItemWidth)
628 						{
629 							auto leftLength = (maxItemWidth - "...".length) / 2;
630 							auto rightLength = maxItemWidth - "...".length - leftLength;
631 							displayedItem =
632 								displayedItem[0 .. leftLength] ~ "..." ~
633 								displayedItem[$ - rightLength .. $];
634 						}
635 						buf.put(displayedItem);
636 					}
637 
638 					rawWrite(y, 0, buf.get(), child is selection ? A_REVERSE : 0);
639 				}
640 				attroff(A_REVERSE);
641 
642 				foreach (i, line; textLines)
643 				{
644 					auto y = cast(int)(contentAreaHeight + i);
645 					y += 2;
646 					if (y == h - 2 && i + 1 < textLines.length)
647 					{
648 						mvprintw(y, 0, " --- more - press i to view --- ");
649 						break;
650 					}
651 					mvhline(y, 0, i ? ' ' : '-', w);
652 					mvprintw(y, 0, "%.*s", line.length, line.ptr);
653 				}
654 				break;
655 			}
656 
657 			case Mode.info:
658 			case Mode.help:
659 				foreach (i, line; textLines)
660 				{
661 					auto y = cast(int)(i - top);
662 					if (y < 0 || y >= contentAreaHeight)
663 						continue;
664 					y += 2;
665 					mvprintw(y, 0, "%.*s", line.length, line.ptr);
666 				}
667 				break;
668 		}
669 
670 		refresh();
671 	}
672 
673 	void moveCursor(sizediff_t delta)
674 	{
675 		if (!items.length)
676 			return;
677 		auto pos = items.countUntil(selection);
678 		if (pos < 0)
679 			return;
680 		pos += delta;
681 		if (pos < 0)
682 			pos = 0;
683 		if (pos > items.length - 1)
684 			pos = items.length - 1;
685 		selection = items[pos];
686 	}
687 
688 	// https://github.com/D-Programming-Deimos/ncurses/pull/43
689 	align(1)
690 	struct cchar_t
691 	{
692 		attr_t attr;
693 		wchar_t[CCHARW_MAX] chars;
694 	}
695 
696 	static cchar_t toCChar(dchar c, uint attr)
697 	{
698 		dchar[2] d = [c, 0];
699 		cchar_t cchar;
700 		if (setcchar(cast(deimos.ncurses.curses.cchar_t*)&cchar, d.ptr, attr, 0, null) != OK)
701 			return toCChar('\U0000FFFD', attr);
702 		return cchar;
703 	}
704 
705 	static void rawWrite(int y, int x, const(char)[] str, uint attr)
706 	{
707 		static FastAppender!cchar_t ccharBuf;
708 		ccharBuf.clear();
709 		foreach (dchar c; (cast(string)str).sanitize)
710 			ccharBuf.put(toCChar(c, attr));
711 		mvadd_wchnstr(y, x, cast(deimos.ncurses.curses.cchar_t*)ccharBuf.get().ptr, ccharBuf.get().length.to!int);
712 	}
713 
714 	/// Pausing has the following effects:
715 	/// 1. We send a SIGSTOP to subprocesses, so that they stop working ASAP.
716 	/// 2. We immediately stop reading subprocess output, so that the UI stops updating.
717 	/// 3. We display the paused state in the UI.
718 	void togglePause()
719 	{
720 		paused = !paused;
721 		foreach (ref subprocess; subprocesses)
722 			subprocess.pause(paused);
723 	}
724 
725 	void setSort(SortMode mode)
726 	{
727 		if (sortMode == mode)
728 			reverseSort = !reverseSort;
729 		else
730 		{
731 			sortMode = mode;
732 			reverseSort = false;
733 		}
734 
735 		bool ascending;
736 		final switch (sortMode)
737 		{
738 			case SortMode.name: ascending = !reverseSort; break;
739 			case SortMode.size: ascending =  reverseSort; break;
740 		}
741 
742 		showMessage(format("Sorting by %s (%s)", mode, ["descending", "ascending"][ascending]));
743 	}
744 
745 	bool handleInput()
746 	{
747 		auto ch = getch();
748 
749 		if (ch == ERR)
750 			return false; // no events - would have blocked
751 		else
752 			message = null;
753 
754 		switch (ch)
755 		{
756 			case 'p':
757 				togglePause();
758 				return true;
759 			case '?':
760 			case KEY_F0 + 1:
761 				mode = Mode.help;
762 				top = 0;
763 				break;
764 			default:
765 				// Proceed according to mode
766 		}
767 
768 		final switch (mode)
769 		{
770 			case Mode.browser:
771 				switch (ch)
772 				{
773 					case KEY_LEFT:
774 					case 'h':
775 					case '<':
776 						if (currentPath.parent)
777 						{
778 							selection = currentPath;
779 							currentPath = currentPath.parent;
780 							top = 0;
781 						}
782 						else
783 							showMessage("Already at top-level");
784 						break;
785 					case KEY_RIGHT:
786 					case '\n':
787 						if (selection)
788 						{
789 							currentPath = selection;
790 							selection = null;
791 							top = 0;
792 						}
793 						else
794 							showMessage("Nowhere to descend into");
795 						break;
796 					case KEY_UP:
797 					case 'k':
798 						moveCursor(-1);
799 						break;
800 					case KEY_DOWN:
801 					case 'j':
802 						moveCursor(+1);
803 						break;
804 					case KEY_PPAGE:
805 						moveCursor(-contentAreaHeight);
806 						break;
807 					case KEY_NPAGE:
808 						moveCursor(+contentAreaHeight);
809 						break;
810 					case KEY_HOME:
811 						moveCursor(-items.length);
812 						break;
813 					case KEY_END:
814 						moveCursor(+items.length);
815 						break;
816 					case 'i':
817 						mode = Mode.info;
818 						top = 0;
819 						break;
820 					case 'q':
821 						done = true;
822 						break;
823 					case 'n':
824 						setSort(SortMode.name);
825 						break;
826 					case 's':
827 						setSort(SortMode.size);
828 						break;
829 					case 't':
830 						dirsFirst = !dirsFirst;
831 						showMessage(format("%s directories before files",
832 								dirsFirst ? "Sorting" : "Not sorting"));
833 						break;
834 					case 'g':
835 						ratioDisplayMode++;
836 						ratioDisplayMode %= enumLength!RatioDisplayMode;
837 						showMessage(format("Showing %s", ratioDisplayMode));
838 						break;
839 					default:
840 						// TODO: show message
841 						break;
842 				}
843 				break;
844 
845 			case Mode.info:
846 				switch (ch)
847 				{
848 					case KEY_LEFT:
849 					case 'h':
850 					case '<':
851 						mode = Mode.browser;
852 						if (currentPath.parent)
853 						{
854 							selection = currentPath;
855 							currentPath = currentPath.parent;
856 							top = 0;
857 						}
858 						break;
859 					case 'q':
860 					case 27: // ESC
861 						if (items.length)
862 							goto case 'i';
863 						else
864 							goto case KEY_LEFT;
865 					case 'i':
866 						mode = Mode.browser;
867 						top = 0;
868 						break;
869 
870 					default:
871 						goto textScroll;
872 				}
873 				break;
874 
875 			case Mode.help:
876 				switch (ch)
877 				{
878 					case 'q':
879 					case 27: // ESC
880 						mode = Mode.browser;
881 						top = 0;
882 						break;
883 
884 					default:
885 						goto textScroll;
886 				}
887 				break;
888 
889 			textScroll:
890 				switch (ch)
891 				{
892 					case KEY_UP:
893 					case 'k':
894 						top += -1;
895 						break;
896 					case KEY_DOWN:
897 					case 'j':
898 						top += +1;
899 						break;
900 					case KEY_PPAGE:
901 						top += -contentAreaHeight;
902 						break;
903 					case KEY_NPAGE:
904 						top += +contentAreaHeight;
905 						break;
906 					case KEY_HOME:
907 						top -= textLines.length;
908 						break;
909 					case KEY_END:
910 						top += textLines.length;
911 						break;
912 					default:
913 						// TODO: show message
914 						break;
915 				}
916 				break;
917 		}
918 
919 		return true;
920 	}
921 }
922 
923 private:
924 
925 /// https://en.wikipedia.org/wiki/1.96
926 // enum z_975 = normalDistributionInverse(0.975);
927 enum z_975 = 1.96;
928 
929 // https://stackoverflow.com/q/69420422/21501
930 // https://stats.stackexchange.com/q/546878/234615
931 double estimateError(
932 	/// Total samples
933 	double n,
934 	/// Samples within the item
935 	double m,
936 	/// Standard score for desired confidence
937 	/// (default is for 95% confidence)
938 	double z = z_975,
939 )
940 {
941 	import std.math.algebraic : sqrt;
942 
943 	auto p = m / n;
944 	auto q = 1 - p;
945 
946 	auto error = sqrt((p * q) / n);
947 	return z * error;
948 }
949 
950 string humanSize(real size)
951 {
952 	static immutable prefixChars = " KMGTPEZY";
953 	size_t power = 0;
954 	while (size > 1024 && power + 1 < prefixChars.length)
955 	{
956 		size /= 1024;
957 		power++;
958 	}
959 	return format("%3.1f %s%sB", size, prefixChars[power], prefixChars[power] == ' ' ? ' ' : 'i');
960 }
961 
962 string toDecimalString(Duration d)
963 {
964 	assert(d >= Duration.zero);
965 	auto ticks = d.stdTime;
966 	enum secondsPerTick = 1.seconds / 1.stdDur;
967 	static assert(secondsPerTick == 10L ^^ 7);
968 	return format!"%d.%07d seconds"(ticks / secondsPerTick, ticks % secondsPerTick);
969 }
970 
971 /// Helper type for formatting pointers without passing their contents by-value.
972 /// Helps preserve the SubPath invariant (which would be broken by copying).
973 struct PointerWriter(T)
974 {
975 	T* ptr;
976 	void toString(scope void delegate(const(char)[]) sink) const
977 	{
978 		ptr.toString(sink);
979 	}
980 }
981 PointerWriter!T pointerWriter(T)(T* ptr) { return PointerWriter!T(ptr); }
982 
983 static immutable string[] help = q"EOF
984 btdu - the sampling disk usage profiler for btrfs
985 -------------------------------------------------
986 
987 Keys:
988 
989       F1, ? - Show this help screen
990       Up, k - Move cursor up
991     Down, j - Move cursor down
992 Right/Enter - Open selected node
993  Left, <, h - Return to parent node
994           p - Pause/resume
995           n - Sort by name (ascending/descending)
996           s - Sort by size (ascending/descending)
997           t - Toggle dirs before files when sorting
998           g - Show percentage and/or graph
999           i - Expand/collapse information panel
1000           q - Close information panel or quit btdu
1001 
1002 Press q to exit this help screen and return to btdu.
1003 
1004 For terminology explanations, see:
1005 https://github.com/CyberShadow/btdu/blob/master/CONCEPTS.md
1006 
1007 https://github.com/CyberShadow/btdu
1008 Created by: Vladimir Panteleev <https://cy.md/>
1009 EOF".splitLines;