diff --git a/src/browser.zig b/src/browser.zig
index 9024f75cc0ace42c9a0f26d259cd255759657b2f..2fffae289001b2befc718c265b202a624d6923f2 100644
--- a/src/browser.zig
+++ b/src/browser.zig
@@ -2,6 +2,154 @@ const std = @import("std");
 const main = @import("main.zig");
 const model = @import("model.zig");
 const ui = @import("ui.zig");
+usingnamespace @import("util.zig");
+
+// Sorted list of all items in the currently opened directory.
+// (first item may be null to indicate the "parent directory" item)
+var dir_items = std.ArrayList(?*model.Entry).init(main.allocator);
+
+// Currently opened directory and its parents.
+var dir_parents = model.Parents{};
+
+fn sortIntLt(a: anytype, b: @TypeOf(a)) ?bool {
+    return if (a == b) null else if (main.config.sort_order == .asc) a < b else a > b;
+}
+
+fn sortLt(_: void, ap: ?*model.Entry, bp: ?*model.Entry) bool {
+    const a = ap.?;
+    const b = bp.?;
+
+    if (main.config.sort_dirsfirst and (a.etype == .dir) != (b.etype == .dir))
+        return a.etype == .dir;
+
+    switch (main.config.sort_col) {
+        .name => {}, // name sorting is the fallback
+        .blocks => {
+            if (sortIntLt(a.blocks, b.blocks)) |r| return r;
+            if (sortIntLt(a.size, b.size)) |r| return r;
+        },
+        .size => {
+            if (sortIntLt(a.size, b.size)) |r| return r;
+            if (sortIntLt(a.blocks, b.blocks)) |r| return r;
+        },
+        .items => {
+            const ai = if (a.dir()) |d| d.total_items else 0;
+            const bi = if (b.dir()) |d| d.total_items else 0;
+            if (sortIntLt(ai, bi)) |r| return r;
+            if (sortIntLt(a.blocks, b.blocks)) |r| return r;
+            if (sortIntLt(a.size, b.size)) |r| return r;
+        },
+        .mtime => {
+            if (!a.isext or !b.isext) return a.isext;
+            if (sortIntLt(a.ext().?.mtime, b.ext().?.mtime)) |r| return r;
+        },
+    }
+
+    // TODO: Unicode-aware sorting might be nice (and slow)
+    const an = a.name();
+    const bn = b.name();
+    return if (main.config.sort_order == .asc) std.mem.lessThan(u8, an, bn)
+           else std.mem.lessThan(u8, bn, an) or std.mem.eql(u8, an, bn);
+}
+
+// Should be called when:
+// - config.sort_* changes
+// - dir_items changes (i.e. from loadDir())
+// - files in this dir have changed in a way that affects their ordering
+fn sortDir() void {
+    // No need to sort the first item if that's the parent dir reference,
+    // excluding that allows sortLt() to ignore null values.
+    const lst = dir_items.items[(if (dir_items.items.len > 0 and dir_items.items[0] == null) @as(usize, 1) else 0)..];
+    std.sort.sort(?*model.Entry, lst, @as(void, undefined), sortLt);
+    // TODO: Fixup selected item index
+}
+
+// Must be called when:
+// - dir_parents changes (i.e. we change directory)
+// - config.show_hidden changes
+// - files in this dir have been added or removed
+fn loadDir() !void {
+    dir_items.shrinkRetainingCapacity(0);
+    if (dir_parents.top() != model.root)
+        try dir_items.append(null);
+    var it = dir_parents.top().sub;
+    while (it) |e| {
+        if (main.config.show_hidden) // fast path
+            try dir_items.append(e)
+        else {
+            const excl = if (e.file()) |f| f.excluded else false;
+            const name = e.name();
+            if (!excl and name[0] != '.' and name[name.len-1] != '~')
+                try dir_items.append(e);
+        }
+        it = e.next;
+    }
+    sortDir();
+}
+
+// Open the given dir for browsing; takes ownership of the Parents struct.
+pub fn open(dir: model.Parents) !void {
+    dir_parents.deinit();
+    dir_parents = dir;
+    try loadDir();
+
+    // TODO: Load view & cursor position if we've opened this dir before.
+}
+
+const Row = struct {
+    row: u32,
+    col: u32 = 0,
+    bg: ui.Bg = .default,
+    item: ?*model.Entry,
+
+    const Self = @This();
+
+    fn flag(self: *Self) !void {
+        defer self.col += 2;
+        const item = self.item orelse return;
+        const ch: u7 = ch: {
+            if (item.file()) |f| {
+                if (f.err) break :ch '!';
+                if (f.excluded) break :ch '<';
+                if (f.other_fs) break :ch '>';
+                if (f.kernfs) break :ch '^';
+                if (f.notreg) break :ch '@';
+            } else if (item.dir()) |d| {
+                if (d.err) break :ch '!';
+                if (d.suberr) break :ch '.';
+                if (d.sub == null) break :ch 'e';
+            } else if (item.link()) |_| break :ch 'H';
+            return;
+        };
+        ui.move(self.row, self.col);
+        self.bg.fg(.flag);
+        ui.addch(ch);
+    }
+
+    fn size(self: *Self) !void {
+        defer self.col += if (main.config.si) @as(u32, 9) else 10;
+        const item = self.item orelse return;
+        ui.move(self.row, self.col);
+        ui.addsize(self.bg, if (main.config.show_blocks) blocksToSize(item.blocks) else item.size);
+        // TODO: shared sizes
+    }
+
+    fn name(self: *Self) !void {
+        ui.move(self.row, self.col);
+        self.bg.fg(.default);
+        if (self.item) |i| {
+            ui.addch(if (i.etype == .dir) '/' else ' ');
+            ui.addstr(try ui.shorten(try ui.toUtf8(i.name()), saturateSub(ui.cols, saturateSub(self.col, 1))));
+        } else
+            ui.addstr("/..");
+    }
+
+    fn draw(self: *Self) !void {
+        try self.flag();
+        try self.size();
+        try self.name();
+    }
+};
 
 pub fn draw() !void {
     ui.style(.hd);
@@ -13,19 +161,35 @@ pub fn draw() !void {
     ui.addch('?');
     ui.style(.hd);
     ui.addstr(" for help");
-    // TODO: [imported]/[readonly] indicators
+    if (main.config.read_only) {
+        ui.move(0, saturateSub(ui.cols, 10));
+        ui.addstr("[readonly]");
+    }
+    // TODO: [imported] indicator
 
     ui.style(.default);
     ui.move(1,0);
     ui.hline('-', ui.cols);
     ui.move(1,3);
     ui.addch(' ');
-    ui.addstr(try ui.shorten(try ui.toUtf8(model.root.entry.name()), std.math.sub(u32, ui.cols, 5) catch 4));
+    ui.addstr(try ui.shorten(try ui.toUtf8(model.root.entry.name()), saturateSub(ui.cols, 5)));
     ui.addch(' ');
 
+    var i: u32 = 0;
+    while (i < saturateSub(ui.rows, 3)) : (i += 1) {
+        if (i >= dir_items.items.len) break;
+        var row = Row{ .row = i+2, .item = dir_items.items[i] };
+        try row.draw();
+    }
+
     ui.style(.hd);
     ui.move(ui.rows-1, 0);
     ui.hline(' ', ui.cols);
     ui.move(ui.rows-1, 1);
-    ui.addstr("No items to display.");
+    ui.addstr("Total disk usage: ");
+    ui.addsize(.hd, blocksToSize(dir_parents.top().entry.blocks));
+    ui.addstr("  Apparent size: ");
+    ui.addsize(.hd, dir_parents.top().entry.size);
+    ui.addstr("  Items: ");
+    ui.addnum(.hd, dir_parents.top().total_items);
 }
diff --git a/src/main.zig b/src/main.zig
index cb6043cd78267712b75c7f0eea3a5856b53dd8df..04d299121d8fdde3bc8509e9ff60be759a06a17a 100644
--- a/src/main.zig
+++ b/src/main.zig
@@ -23,6 +23,12 @@ pub const Config = struct {
     ui_color: enum { off, dark } = .off,
     thousands_sep: []const u8 = ".",
 
+    show_hidden: bool = true,
+    show_blocks: bool = true,
+    sort_col: enum { name, blocks, size, items, mtime } = .blocks,
+    sort_order: enum { asc, desc } = .desc,
+    sort_dirsfirst: bool = false,
+
     read_only: bool = false,
     can_shell: bool = true,
     confirm_quit: bool = false,
@@ -239,9 +245,11 @@ pub fn main() anyerror!void {
         ui.die("The --exclude-kernfs tag is currently only supported on Linux.\n", .{});
 
     try scan.scanRoot(scan_dir orelse ".");
+    try browser.open(model.Parents{});
 
     ui.init();
     defer ui.deinit();
+
     try browser.draw();
 
     _ = ui.c.getch();
diff --git a/src/model.zig b/src/model.zig
index 34e3aa975e04ba62080b7b6008da1c16ebd3c0a0..48340dd4b86c13083a39519da3ab93a6187c8833 100644
--- a/src/model.zig
+++ b/src/model.zig
@@ -1,24 +1,15 @@
 const std = @import("std");
 const main = @import("main.zig");
+usingnamespace @import("util.zig");
 
 // While an arena allocator is optimimal for almost all scenarios in which ncdu
 // is used, it doesn't allow for re-using deleted nodes after doing a delete or
 // refresh operation, so a long-running ncdu session with regular refreshes
 // will leak memory, but I'd say that's worth the efficiency gains.
-// (TODO: Measure, though. Might as well use a general purpose allocator if the
-// memory overhead turns out to be insignificant.)
+// TODO: Can still implement a simple bucketed free list on top of this arena
+// allocator to reuse nodes, if necessary.
 var allocator = std.heap.ArenaAllocator.init(std.heap.page_allocator);
 
-fn saturateAdd(a: anytype, b: @TypeOf(a)) @TypeOf(a) {
-    std.debug.assert(@typeInfo(@TypeOf(a)).Int.signedness == .unsigned);
-    return std.math.add(@TypeOf(a), a, b) catch std.math.maxInt(@TypeOf(a));
-}
-
-fn saturateSub(a: anytype, b: @TypeOf(a)) @TypeOf(a) {
-    std.debug.assert(@typeInfo(@TypeOf(a)).Int.signedness == .unsigned);
-    return std.math.sub(@TypeOf(a), a, b) catch std.math.minInt(@TypeOf(a));
-}
-
 pub const EType = packed enum(u2) { dir, link, file };
 
 // Memory layout:
@@ -57,7 +48,7 @@ pub const Entry = packed struct {
         return if (self.etype == .file) @ptrCast(*File, self) else null;
     }
 
-    fn name_offset(etype: EType) usize {
+    fn nameOffset(etype: EType) usize {
         return switch (etype) {
             .dir => @byteOffsetOf(Dir, "name"),
             .link => @byteOffsetOf(Link, "name"),
@@ -66,25 +57,25 @@ pub const Entry = packed struct {
     }
 
     pub fn name(self: *const Self) [:0]const u8 {
-        const ptr = @intToPtr([*:0]u8, @ptrToInt(self) + name_offset(self.etype));
+        const ptr = @intToPtr([*:0]u8, @ptrToInt(self) + nameOffset(self.etype));
         return ptr[0..std.mem.lenZ(ptr) :0];
     }
 
     pub fn ext(self: *Self) ?*Ext {
         if (!self.isext) return null;
         const n = self.name();
-        return @intToPtr(*Ext, std.mem.alignForward(@ptrToInt(self) + name_offset(self.etype) + n.len + 1, @alignOf(Ext)));
+        return @intToPtr(*Ext, std.mem.alignForward(@ptrToInt(self) + nameOffset(self.etype) + n.len + 1, @alignOf(Ext)));
     }
 
     pub fn create(etype: EType, isext: bool, ename: []const u8) !*Entry {
-        const base_size = name_offset(etype) + ename.len + 1;
+        const base_size = nameOffset(etype) + ename.len + 1;
         const size = (if (isext) std.mem.alignForward(base_size, @alignOf(Ext))+@sizeOf(Ext) else base_size);
         var ptr = try allocator.allocator.allocWithOptions(u8, size, @alignOf(Entry), null);
         std.mem.set(u8, ptr, 0); // kind of ugly, but does the trick
         var e = @ptrCast(*Entry, ptr);
         e.etype = etype;
         e.isext = isext;
-        var name_ptr = @intToPtr([*]u8, @ptrToInt(e) + name_offset(etype));
+        var name_ptr = @intToPtr([*]u8, @ptrToInt(e) + nameOffset(etype));
         std.mem.copy(u8, name_ptr[0..ename.len], ename);
         //std.debug.warn("{any}\n", .{ @ptrCast([*]u8, e)[0..size] });
         return e;
@@ -145,8 +136,8 @@ pub const Entry = packed struct {
                 add_total = true;
             }
             if(add_total) {
-                p.total_size = saturateAdd(p.total_size, self.size);
-                p.total_blocks = saturateAdd(p.total_blocks, self.blocks);
+                p.entry.size = saturateAdd(p.entry.size, self.size);
+                p.entry.blocks = saturateAdd(p.entry.blocks, self.blocks);
                 p.total_items = saturateAdd(p.total_items, 1);
             }
         }
@@ -160,17 +151,15 @@ pub const Dir = packed struct {
 
     sub: ?*Entry,
 
-    // total_*: Total size of all unique files + dirs. Non-shared hardlinks are counted only once.
+    // entry.{blocks,size}: Total size of all unique files + dirs. Non-shared hardlinks are counted only once.
     //   (i.e. the space you'll need if you created a filesystem with only this dir)
     // shared_*: Unique hardlinks that still have references outside of this directory.
     //   (i.e. the space you won't reclaim by deleting this dir)
-    // (space reclaimed by deleting a dir =~ total_ - shared_)
-    total_blocks: u64,
+    // (space reclaimed by deleting a dir =~ entry. - shared_)
     shared_blocks: u64,
-    total_size: u64,
     shared_size: u64,
-    total_items: u32,
     shared_items: u32,
+    total_items: u32,
     // TODO: ncdu1 only keeps track of a total item count including duplicate hardlinks.
     // That number seems useful, too. Include it somehow?
 
@@ -355,6 +344,10 @@ pub const Parents = struct {
             i += 1;
         }
     }
+
+    pub fn deinit(self: *Self) void {
+        self.stack.deinit();
+    }
 };
 
 test "name offsets" {
diff --git a/src/scan.zig b/src/scan.zig
index ee673d8a05450cab737b670e12428ff2ee504509..d640150b6dd4f0b03357f434a43444de7ba565f3 100644
--- a/src/scan.zig
+++ b/src/scan.zig
@@ -217,13 +217,7 @@ fn scanDir(ctx: *Context, dir: std.fs.Dir) std.mem.Allocator.Error!void {
         var e = try model.Entry.create(etype, main.config.extended, entry.name);
         e.blocks = stat.blocks;
         e.size = stat.size;
-        if (e.dir()) |d| {
-            d.dev = try model.getDevId(stat.dev);
-            // The dir entry itself also counts.
-            d.total_blocks = stat.blocks;
-            d.total_size = stat.size;
-            d.total_items = 1;
-        }
+        if (e.dir()) |d| d.dev = try model.getDevId(stat.dev);
         if (e.file()) |f| f.notreg = !stat.dir and !stat.reg;
         if (e.link()) |l| {
             l.ino = stat.ino;
diff --git a/src/ui.zig b/src/ui.zig
index 14608bd880d778191115946c4a83a811a2ca8f35..a685624b082f8ab94e3ae355ce7d57df41283ae3 100644
--- a/src/ui.zig
+++ b/src/ui.zig
@@ -209,7 +209,7 @@ const styles = [_]StyleDef{
         .dark = .{ .fg = c.COLOR_MAGENTA, .bg = c.COLOR_GREEN,  .attr = 0 } },
 };
 
-const Style = lbl: {
+pub const Style = lbl: {
     var fields: [styles.len]std.builtin.TypeInfo.EnumField = undefined;
     var decls = [_]std.builtin.TypeInfo.Declaration{};
     inline for (styles) |s, i| {
@@ -229,6 +229,35 @@ const Style = lbl: {
     });
 };
 
+const ui = @This();
+
+pub const Bg = enum {
+    default, hd, sel,
+
+    // Set the style to the selected bg combined with the given fg.
+    pub fn fg(self: @This(), s: Style) void {
+        ui.style(switch (self) {
+            .default => s,
+            .hd =>
+                switch (s) {
+                    .default => Style.hd,
+                    .key => Style.key_hd,
+                    .num => Style.num_hd,
+                    else => unreachable,
+                },
+            .sel =>
+                switch (s) {
+                    .default => Style.sel,
+                    .num => Style.num_sel,
+                    .dir => Style.dir_sel,
+                    .flag => Style.flag_sel,
+                    .graph => Style.graph_sel,
+                    else => unreachable,
+                }
+        });
+    }
+};
+
 fn updateSize() void {
     // getmax[yx] macros are marked as "legacy", but Zig can't deal with the "proper" getmaxyx macro.
     rows = @intCast(u32, c.getmaxy(c.stdscr));
@@ -287,6 +316,63 @@ pub fn addch(ch: c.chtype) void {
     _ = c.addch(ch);
 }
 
+// Print a human-readable size string, formatted into the given bavkground.
+// Takes 8 columns in SI mode, 9 otherwise.
+//   "###.# XB"
+//   "###.# XiB"
+pub fn addsize(bg: Bg, v: u64) void {
+    var f = @intToFloat(f32, v);
+    var unit: [:0]const u8 = undefined;
+    if (main.config.si) {
+        if(f < 1000.0)    { unit = "  B"; }
+        else if(f < 1e6)  { unit = " KB"; f /= 1e3;  }
+        else if(f < 1e9)  { unit = " MB"; f /= 1e6;  }
+        else if(f < 1e12) { unit = " GB"; f /= 1e9;  }
+        else if(f < 1e15) { unit = " TB"; f /= 1e12; }
+        else if(f < 1e18) { unit = " PB"; f /= 1e15; }
+        else              { unit = " EB"; f /= 1e18; }
+    }
+    else {
+        if(f < 1000.0)       { unit = "   B"; }
+        else if(f < 1023e3)  { unit = " KiB"; f /= 1024.0; }
+        else if(f < 1023e6)  { unit = " MiB"; f /= 1048576.0; }
+        else if(f < 1023e9)  { unit = " GiB"; f /= 1073741824.0; }
+        else if(f < 1023e12) { unit = " TiB"; f /= 1099511627776.0; }
+        else if(f < 1023e15) { unit = " PiB"; f /= 1125899906842624.0; }
+        else                 { unit = " EiB"; f /= 1152921504606846976.0; }
+    }
+    var buf: [8:0]u8 = undefined;
+    _ = std.fmt.bufPrintZ(&buf, "{d:>5.1}", .{f}) catch unreachable;
+    bg.fg(.num);
+    addstr(&buf);
+    bg.fg(.default);
+    addstr(unit);
+}
+
+// Print a full decimal number with thousand separators.
+// Max: 18,446,744,073,709,551,615 -> 26 columns
+// (Assuming thousands_sep takes a single column)
+pub fn addnum(bg: Bg, v: u64) void {
+    var buf: [32]u8 = undefined;
+    const s = std.fmt.bufPrint(&buf, "{d}", .{v}) catch unreachable;
+    var f: [64:0]u8 = undefined;
+    var i: usize = 0;
+    for (s) |digit, n| {
+        if (n != 0 and (s.len - n) % 3 == 0) {
+            for (main.config.thousands_sep) |ch| {
+                f[i] = ch;
+                i += 1;
+            }
+        }
+        f[i] = digit;
+        i += 1;
+    }
+    f[i] = 0;
+    bg.fg(.num);
+    addstr(&f);
+    bg.fg(.default);
+}
+
 pub fn hline(ch: c.chtype, len: u32) void {
     _ = c.hline(ch, @intCast(i32, len));
 }
diff --git a/src/util.zig b/src/util.zig
new file mode 100644
index 0000000000000000000000000000000000000000..3b6986ad8666b362fbe7525c84af0484c7a619d3
--- /dev/null
+++ b/src/util.zig
@@ -0,0 +1,16 @@
+const std = @import("std");
+
+pub fn saturateAdd(a: anytype, b: @TypeOf(a)) @TypeOf(a) {
+    std.debug.assert(@typeInfo(@TypeOf(a)).Int.signedness == .unsigned);
+    return std.math.add(@TypeOf(a), a, b) catch std.math.maxInt(@TypeOf(a));
+}
+
+pub fn saturateSub(a: anytype, b: @TypeOf(a)) @TypeOf(a) {
+    std.debug.assert(@typeInfo(@TypeOf(a)).Int.signedness == .unsigned);
+    return std.math.sub(@TypeOf(a), a, b) catch std.math.minInt(@TypeOf(a));
+}
+
+// Multiplies by 512, saturating.
+pub fn blocksToSize(b: u64) u64 {
+    return if (b & 0xFF80000000000000 > 0) std.math.maxInt(u64) else b << 9;
+}