From 8d7fdeb633f365b2634ead9553ccfa278da6a4c6 Mon Sep 17 00:00:00 2001
From: Kyle Reed <kareed@kallanreed.com>
Date: Thu, 1 Jun 2023 15:45:55 -0700
Subject: [PATCH] Add edit support for Notepad (#1093)

* WIP file editing

* WIP file editing

* Add "on_pop" handler to navigation.

* WIP Editing

* WIP for draft

* Fix mock and unit tests,  support +newline at end.

* Clean up Painter API and use string_view

* Fix optional rvalue functions

* Fix Result 'take' to be more standard

* FileWrapper stack buffer reads

* Grasping at straws

* Nit

* Move set_on_pop impl to cpp

* Workaround "Open" when file not dirty.

---------

Co-authored-by: kallanreed <kallanreed@outlook.com>
---
 firmware/application/apps/ui_text_editor.cpp  | 192 +++++++--
 firmware/application/apps/ui_text_editor.hpp  |  29 +-
 firmware/application/file.cpp                 |  22 +-
 firmware/application/file.hpp                 |  12 +-
 firmware/application/file_wrapper.hpp         | 217 ++++++++--
 firmware/application/ui_navigation.cpp        |  67 ++--
 firmware/application/ui_navigation.hpp        |  18 +-
 firmware/common/optional.hpp                  |   6 +-
 firmware/common/ui_painter.cpp                |  36 +-
 firmware/common/ui_painter.hpp                |  24 +-
 .../test/application/test_file_wrapper.cpp    | 372 +++++++++++++++++-
 11 files changed, 847 insertions(+), 148 deletions(-)

diff --git a/firmware/application/apps/ui_text_editor.cpp b/firmware/application/apps/ui_text_editor.cpp
index 1329e049..675d2e74 100644
--- a/firmware/application/apps/ui_text_editor.cpp
+++ b/firmware/application/apps/ui_text_editor.cpp
@@ -124,6 +124,22 @@ bool TextViewer::on_encoder(EncoderEvent delta) {
     return updated;
 }
 
+void TextViewer::redraw(bool redraw_text) {
+    paint_state_.redraw_text = redraw_text;
+    set_dirty();
+}
+
+uint32_t TextViewer::offset() const {
+    auto range = file_->line_range(cursor_.line);
+    if (range)
+        return range->start + col();
+    return 0;
+}
+
+uint16_t TextViewer::line_length() {
+    return file_->line_length(cursor_.line);
+}
+
 bool TextViewer::apply_scrolling_constraints(int16_t delta_line, int16_t delta_col) {
     if (!has_file())
         return false;
@@ -175,28 +191,24 @@ bool TextViewer::apply_scrolling_constraints(int16_t delta_line, int16_t delta_c
     return true;
 }
 
-void TextViewer::redraw(bool redraw_text) {
-    paint_state_.redraw_text = redraw_text;
-    set_dirty();
-}
-
 void TextViewer::paint_text(Painter& painter, uint32_t line, uint16_t col) {
     auto r = screen_rect();
+    char buffer[max_col + 1];
 
     // Draw the lines from the file
     for (auto i = 0u; i < max_line; ++i) {
         if (line + i >= file_->line_count())
             break;
 
-        auto str = file_->get_text(line + i, col, max_col);
+        auto result = file_->get_text(line + i, col, buffer, max_col);
 
-        if (str && str->length() > 0)
+        if (result && *result > 0)
             painter.draw_string(
                 {0, r.top() + (int)i * char_height},
-                style_text, *str);
+                style_text, {buffer, *result});
 
         // Clear empty line sections. This is less visually jarring than full clear.
-        int32_t clear_width = max_col - (str ? str->length() : 0);
+        int32_t clear_width = max_col - (result ? *result : 0);
         if (clear_width > 0)
             painter.fill_rectangle(
                 {(max_col - clear_width) * char_width,
@@ -238,10 +250,6 @@ void TextViewer::reset_file(FileWrapper* file) {
     redraw(true);
 }
 
-uint16_t TextViewer::line_length() {
-    return file_->line_length(cursor_.line);
-}
-
 /* TextEditorMenu ***************************************************/
 
 TextEditorMenu::TextEditorMenu()
@@ -309,25 +317,52 @@ TextEditorView::TextEditorView(NavigationView& nav)
     menu.on_copy() = [this]() {
         show_nyi();
     };
+
     menu.on_delete_line() = [this]() {
-        show_nyi();
+        prepare_for_write();
+        file_->delete_line(viewer.line());
+        refresh_ui();
+        hide_menu(true);
     };
+
     menu.on_edit_line() = [this]() {
-        show_nyi();
+        show_edit_line();
     };
+
     menu.on_add_line() = [this]() {
-        show_nyi();
+        prepare_for_write();
+
+        if (viewer.offset() < file_->size() - 1)
+            file_->insert_line(viewer.line());
+        else
+            file_->insert_line(-1);  // Add after last line.
+
+        refresh_ui();
+        hide_menu(true);
     };
+
     menu.on_open() = [this]() {
-        // TODO: confirm.
-        show_file_picker();
+        /*show_save_prompt([this]() {
+            show_file_picker();
+        });*/
+        // HACK: above should work but it's faulting.
+        if (!file_dirty_) {
+            show_file_picker();
+        } else {
+            show_save_prompt(nullptr);
+            show_file_picker(false);
+        }
     };
+
     menu.on_save() = [this]() {
-        show_nyi();
+        save_temp_file();
+        hide_menu(true);
     };
+
     menu.on_exit() = [this]() {
-        // TODO: confirm.
-        nav_.pop();
+        show_save_prompt([this]() {
+            nav_.pop();
+        });
     };
 
     button_menu.on_select = [this]() {
@@ -345,6 +380,10 @@ TextEditorView::TextEditorView(NavigationView& nav, const fs::path& path)
     open_file(path);
 }
 
+TextEditorView::~TextEditorView() {
+    delete_temp_file();
+}
+
 void TextEditorView::on_show() {
     if (file_)
         viewer.focus();
@@ -353,14 +392,21 @@ void TextEditorView::on_show() {
 }
 
 void TextEditorView::open_file(const fs::path& path) {
+    file_.reset();
+    viewer.clear_file();
+    delete_temp_file();
+
+    path_ = {};
+    file_dirty_ = false;
+    has_temp_file_ = false;
     auto result = FileWrapper::open(path);
 
     if (!result) {
         nav_.display_modal("Read Error", "Cannot open file:\n" + result.error().what());
-        file_.reset();
-        viewer.clear_file();
+
     } else {
-        file_ = result.take();
+        file_ = *std::move(result);
+        path_ = path;
         viewer.set_file(*file_);
     }
 
@@ -401,16 +447,100 @@ void TextEditorView::hide_menu(bool hidden) {
     set_dirty();
 }
 
-void TextEditorView::show_file_picker() {
-    auto open_view = nav_.push<FileLoadView>("");
-    open_view->on_changed = [this](std::filesystem::path path) {
-        open_file(path);
-        hide_menu();
-    };
+void TextEditorView::show_file_picker(bool immediate) {
+    // TODO: immediate is a hack until nav_.on_pop is fixed.
+    auto open_view = immediate ? nav_.push<FileLoadView>("") : nav_.push_under_current<FileLoadView>("");
+
+    if (open_view) {
+        open_view->on_changed = [this](std::filesystem::path path) {
+            open_file(path);
+            hide_menu();
+        };
+    }
+}
+
+void TextEditorView::show_edit_line() {
+    auto str = file_->get_text(viewer.line(), 0, viewer.line_length());
+    if (!str) {
+        nav_.display_modal("Error", "Failed to get line text.");
+        return;
+    }
+
+    edit_line_buffer_ = *std::move(str);
+
+    text_prompt(
+        nav_,
+        edit_line_buffer_,
+        viewer.col(),
+        max_edit_length,
+        [this](std::string& buffer) {
+            auto range = file_->line_range(viewer.line());
+            if (!range)
+                return;
+
+            prepare_for_write();
+            file_->replace_range(*range, buffer);
+        });
+    nav_.set_on_pop([this]() {
+        edit_line_buffer_.clear();
+        refresh_ui();
+        hide_menu(true);
+    });
 }
 
 void TextEditorView::show_nyi() {
     nav_.display_modal("Soon...", "Coming soon.");
 }
 
-}  // namespace ui
\ No newline at end of file
+void TextEditorView::show_save_prompt(std::function<void()> continuation) {
+    if (!file_dirty_) {
+        if (continuation)
+            continuation();
+        return;
+    }
+
+    nav_.display_modal(
+        "Save?", "Save changes?", YESNO,
+        [this](bool choice) {
+            if (choice)
+                save_temp_file();
+        });
+    nav_.set_on_pop(continuation);
+}
+
+void TextEditorView::prepare_for_write() {
+    file_dirty_ = true;
+
+    if (has_temp_file_)
+        return;
+
+    // Copy to temp file on write.
+    has_temp_file_ = true;
+    delete_temp_file();
+    copy_file(path_, get_temp_path());
+    file_->assume_file(get_temp_path());
+}
+
+void TextEditorView::delete_temp_file() const {
+    auto temp_path = get_temp_path();
+    if (!temp_path.empty()) {
+        delete_file(temp_path);
+    }
+}
+
+void TextEditorView::save_temp_file() {
+    if (file_dirty_) {
+        delete_file(path_);
+        copy_file(get_temp_path(), path_);
+        file_dirty_ = false;
+    }
+}
+
+fs::path TextEditorView::get_temp_path() const {
+    if (!path_.empty())
+        return path_ + "~";
+
+    return {};
+}
+
+}  // namespace ui
diff --git a/firmware/application/apps/ui_text_editor.hpp b/firmware/application/apps/ui_text_editor.hpp
index 9f4ac65a..2314af05 100644
--- a/firmware/application/apps/ui_text_editor.hpp
+++ b/firmware/application/apps/ui_text_editor.hpp
@@ -19,6 +19,10 @@
  * Boston, MA 02110-1301, USA.
  */
 
+/* TODO:
+ * - Busy indicator while reading files.
+ */
+
 #ifndef __UI_TEXT_EDITOR_H__
 #define __UI_TEXT_EDITOR_H__
 
@@ -66,6 +70,10 @@ class TextViewer : public Widget {
 
     uint32_t line() const { return cursor_.line; }
     uint32_t col() const { return cursor_.col; }
+    uint32_t offset() const;
+
+    // Gets the length of the current line.
+    uint16_t line_length();
 
    private:
     static constexpr int8_t char_width = 5;
@@ -89,9 +97,6 @@ class TextViewer : public Widget {
 
     void reset_file(FileWrapper* file = nullptr);
 
-    // Gets the length of the current line.
-    uint16_t line_length();
-
     FileWrapper* file_{};
 
     struct {
@@ -201,6 +206,7 @@ class TextEditorView : public View {
     TextEditorView(
         NavigationView& nav,
         const std::filesystem::path& path);
+    ~TextEditorView();
 
     std::string title() const override {
         return "Notepad";
@@ -209,15 +215,30 @@ class TextEditorView : public View {
     void on_show() override;
 
    private:
+    static constexpr size_t max_edit_length = 1024;
+    std::string edit_line_buffer_{};
+
     void open_file(const std::filesystem::path& path);
+    void save_file();
     void refresh_ui();
     void update_position();
     void hide_menu(bool hidden = true);
-    void show_file_picker();
+    void show_file_picker(bool immediate = true);
+    void show_edit_line();
     void show_nyi();
+    void show_save_prompt(std::function<void()> continuation);
+
+    void prepare_for_write();
+    void create_temp_file() const;
+    void delete_temp_file() const;
+    void save_temp_file();
+    std::filesystem::path get_temp_path() const;
 
     NavigationView& nav_;
     std::unique_ptr<FileWrapper> file_{};
+    std::filesystem::path path_{};
+    bool file_dirty_{false};
+    bool has_temp_file_{false};
 
     TextViewer viewer{
         /* 272 = 320 - 16 (top bar) - 32 (bottom controls) */
diff --git a/firmware/application/file.cpp b/firmware/application/file.cpp
index dbbeabd7..ea7bdb33 100644
--- a/firmware/application/file.cpp
+++ b/firmware/application/file.cpp
@@ -44,8 +44,9 @@ Optional<File::Error> File::open_fatfs(const std::filesystem::path& filename, BY
     }
 }
 
-Optional<File::Error> File::open(const std::filesystem::path& filename) {
-    return open_fatfs(filename, FA_READ);
+Optional<File::Error> File::open(const std::filesystem::path& filename, bool read_only) {
+    BYTE mode = read_only ? FA_READ : FA_READ | FA_WRITE;
+    return open_fatfs(filename, mode);
 }
 
 Optional<File::Error> File::append(const std::filesystem::path& filename) {
@@ -97,6 +98,15 @@ File::Result<File::Offset> File::seek(Offset new_position) {
     return {static_cast<File::Offset>(old_position)};
 }
 
+File::Result<File::Offset> File::truncate() {
+    const auto position = f_tell(&f);
+    const auto result = f_truncate(&f);
+    if (result != FR_OK) {
+        return {static_cast<Error>(result)};
+    }
+    return {static_cast<File::Offset>(position)};
+}
+
 File::Size File::size() const {
     return f_size(&f);
 }
@@ -248,19 +258,19 @@ std::filesystem::filesystem_error copy_file(
     File dst;
 
     auto error = src.open(file_path);
-    if (error.is_valid()) return error.value();
+    if (error) return error.value();
 
     error = dst.create(dest_path);
-    if (error.is_valid()) return error.value();
+    if (error) return error.value();
 
     while (true) {
         auto result = src.read(buffer, buffer_size);
         if (result.is_error()) return result.error();
 
-        result = dst.write(buffer, result.value());
+        result = dst.write(buffer, *result);
         if (result.is_error()) return result.error();
 
-        if (result.value() < buffer_size)
+        if (*result < buffer_size)
             break;
     }
 
diff --git a/firmware/application/file.hpp b/firmware/application/file.hpp
index 0245cd7e..a93d5492 100644
--- a/firmware/application/file.hpp
+++ b/firmware/application/file.hpp
@@ -320,13 +320,8 @@ class File {
             return value_;
         }
 
-        /* Allows value to be moved out of the Result. */
-        T take() {
-            if (is_error())
-                return {};
-            T temp;
-            std::swap(temp, value_);
-            return temp;
+        T&& operator*() && {
+            return std::move(value_);
         }
 
         Error error() const {
@@ -369,7 +364,7 @@ class File {
     File& operator=(const File&) = delete;
 
     // TODO: Return Result<>.
-    Optional<Error> open(const std::filesystem::path& filename);
+    Optional<Error> open(const std::filesystem::path& filename, bool read_only = true);
     Optional<Error> append(const std::filesystem::path& filename);
     Optional<Error> create(const std::filesystem::path& filename);
 
@@ -377,6 +372,7 @@ class File {
     Result<Size> write(const void* data, Size bytes_to_write);
 
     Result<Offset> seek(uint64_t Offset);
+    Result<Offset> truncate();
     // Timestamp created_date() const;
     Size size() const;
 
diff --git a/firmware/application/file_wrapper.hpp b/firmware/application/file_wrapper.hpp
index 13db3621..73a8a6d6 100644
--- a/firmware/application/file_wrapper.hpp
+++ b/firmware/application/file_wrapper.hpp
@@ -27,7 +27,7 @@
 #include "optional.hpp"
 
 #include <memory>
-#include <string>
+#include <string_view>
 
 enum class LineEnding : uint8_t {
     LF,
@@ -36,12 +36,20 @@ enum class LineEnding : uint8_t {
 
 /* TODO:
  * - CRLF handling.
+ * - Avoid full re-read on edits.
+ *   - Would need to read old/new text when editing to track newlines.
+ * - How to surface errors? Exceptions?
  */
 
+/* FatFs docs http://elm-chan.org/fsw/ff/00index_e.html */
+
 /* BufferType requires the following members
  * Size size()
  * Result<Size> read(void* data, Size bytes_to_read)
+ * Result<Size> write(const void* data, Size bytes_to_write)
  * Result<Offset> seek(uint32_t offset)
+ * Result<Offset> truncate()
+ * Optional<Error> sync()
  */
 
 /* Wraps a buffer and provides an API for accessing lines efficiently. */
@@ -52,10 +60,12 @@ class BufferWrapper {
     using Line = uint32_t;
     using Column = uint32_t;
     using Range = struct {
-        // Offset of the line start.
+        // Offset of the start, inclusive.
         Offset start;
-        // Offset of one past the line end.
+        // Offset of the end, exclusive.
         Offset end;
+
+        Offset length() const { return end - start; }
     };
 
     BufferWrapper(BufferType* buffer)
@@ -69,6 +79,18 @@ class BufferWrapper {
     BufferWrapper& operator=(const BufferWrapper&) = delete;
 
     Optional<std::string> get_text(Line line, Column col, Offset length) {
+        std::string buffer;
+        buffer.resize(length);
+
+        auto result = get_text(line, col, &buffer[0], length);
+        if (!result)
+            return {};
+
+        buffer.resize(*result);
+        return buffer;
+    }
+
+    Optional<Offset> get_text(Line line, Column col, char* output, Offset length) {
         auto range = line_range(line);
         int32_t to_read = length;
 
@@ -82,7 +104,7 @@ class BufferWrapper {
         if (to_read <= 0)
             return {};
 
-        return read(range->start + col, to_read);
+        return read(range->start + col, output, to_read);
     }
 
     /* Gets the size of the buffer in bytes. */
@@ -95,12 +117,12 @@ class BufferWrapper {
     Optional<Range> line_range(Line line) {
         ensure_cached(line);
 
-        auto offset = offset_for_line(line);
-        if (!offset)
+        auto index = index_for_line(line);
+        if (!index)
             return {};
 
-        auto start = *offset == 0 ? start_offset_ : (newlines_[*offset - 1] + 1);
-        auto end = newlines_[*offset] + 1;
+        auto start = *index == 0 ? start_offset_ : (newlines_[*index - 1] + 1);
+        auto end = newlines_[*index] + 1;
 
         return Range{start, end};
     }
@@ -108,17 +130,66 @@ class BufferWrapper {
     /* Gets the length of the line, or 0 if invalid. */
     Offset line_length(Line line) {
         auto range = line_range(line);
+        return range ? range->length() : 0;
+    }
+
+    /* Gets the buffer offset of the line & col if valid. */
+    Optional<Offset> get_offset(Line line, Column col) {
+        auto range = line_range(line);
 
         if (range)
-            return range->end - range->start;
+            return range->start + col;
 
-        return 0;
+        return {};
     }
 
     /* Gets the index of the first line in the cache.
      * Only really useful for unit testing or diagnostics. */
     Offset start_line() { return start_line_; };
 
+    /* Inserts a line before the specified line or at the
+     * end of the buffer if line >= line_count. */
+    void insert_line(Line line) {
+        auto range = line_range(line);
+
+        if (range)
+            replace_range({range->start, range->start}, "\n");
+        else if (line >= line_count_)
+            replace_range({(Offset)size(), (Offset)size()}, "\n");
+    }
+
+    /* Deletes the specified line. */
+    void delete_line(Line line) {
+        auto range = line_range(line);
+
+        if (range)
+            replace_range(*range, {});
+    }
+
+    /* Replace the specified range with the string contents.
+     * A range with start/end set to the same value will insert.
+     * A range with an empty string will delete. */
+    void replace_range(Range range, std::string_view value) {
+        if (range.start > size() || range.end > size() || range.start > range.end)
+            return;
+
+        /* If delta_length == 0, it's an overwrite. Could still have
+         * added or removed newlines so caches will need to be rebuilt.
+         * If delta_length > 0, the file needs to grow and content needs
+         * to be shifted forward until the end of the range.
+         * If delta_length < 0, the file needs to be truncated and the
+         * content after the value needs to be shifted backward. */
+        int32_t delta_length = value.length() - range.length();
+        if (delta_length > 0)
+            expand(range.end, delta_length);
+        else if (delta_length < 0)
+            shrink(range.end, delta_length);
+
+        write(range.start, value);
+        wrapped_->sync();
+        rebuild_cache();
+    }
+
    protected:
     BufferWrapper() {}
 
@@ -132,12 +203,16 @@ class BufferWrapper {
     static constexpr Offset max_newlines = CacheSize;
 
     /* Size of stack buffer used for reading/writing. */
-    static constexpr size_t buffer_size = 512;
+    static constexpr Offset buffer_size = 512;
 
     void initialize() {
         start_offset_ = 0;
         start_line_ = 0;
         line_count_ = 0;
+        rebuild_cache();
+    }
+
+    void rebuild_cache() {
         newlines_.clear();
 
         // Special case for empty files to keep them consistent.
@@ -147,7 +222,17 @@ class BufferWrapper {
             return;
         }
 
-        Offset offset = 0;
+        // TODO: think through this for edit cases.
+        // E.g. don't read to end, maybe could specify
+        // a range to re-read because it should be possible
+        // to tell where the dirty regions are. After the
+        // dirty region, it should be possible to fixup
+        // the line_count data.
+        // TODO: seems like shrink/expand could do this while
+        // they are running.
+
+        line_count_ = start_line_;
+        Offset offset = start_offset_;
         auto result = next_newline(offset);
 
         while (result) {
@@ -159,25 +244,28 @@ class BufferWrapper {
         }
     }
 
-    Optional<std::string> read(Offset offset, Offset length) {
+    Optional<Offset> read(Offset offset, char* buffer, Offset length) {
         if (offset + length > size())
             return {};
 
-        std::string buffer;
-        buffer.resize(length);
         wrapped_->seek(offset);
 
-        auto result = wrapped_->read(&buffer[0], length);
+        auto result = wrapped_->read(buffer, length);
         if (result.is_error())
-            // TODO: better error handling.
-            return std::string{"[Bad Read]"};
+            return {};
 
-        buffer.resize(*result);
-        return buffer;
+        return *result;
     }
 
-    /* Returns the offset of the line in the newline cache if valid. */
-    Optional<Offset> offset_for_line(Line line) const {
+    bool write(Offset offset, std::string_view value) {
+        wrapped_->seek(offset);
+        auto result = wrapped_->write(value.data(), value.length());
+
+        return result.is_ok();
+    }
+
+    /* Returns the index of the line in the newline cache if valid. */
+    Optional<Offset> index_for_line(Line line) const {
         if (line >= line_count_)
             return {};
 
@@ -193,8 +281,8 @@ class BufferWrapper {
         if (line >= line_count_)
             return;
 
-        auto result = offset_for_line(line);
-        if (result)
+        auto index = index_for_line(line);
+        if (index)
             return;
 
         if (line < start_line_) {
@@ -229,7 +317,7 @@ class BufferWrapper {
         }
     }
 
-    /* Helpers for finding the prev/next newline. */
+    /* Finding the first newline backward from offset. */
     Optional<Offset> previous_newline(Offset offset) {
         char buffer[buffer_size];
         auto to_read = buffer_size;
@@ -264,6 +352,7 @@ class BufferWrapper {
         return {};  // Didn't find one.
     }
 
+    /* Finding the first newline forward from offset. */
     Optional<Offset> next_newline(Offset offset) {
         // EOF, no more newlines to find.
         if (offset >= size())
@@ -295,6 +384,65 @@ class BufferWrapper {
         return size() - 1;
     }
 
+    /* Grow the file and move file content so that the
+     * content at src is shifted forward by 'delta'. */
+    void expand(Offset src, int32_t delta) {
+        if (delta <= 0)  // Not an expand.
+            return;
+
+        char buffer[buffer_size];
+        auto to_read = buffer_size;
+
+        // Number of bytes left to shift.
+        Offset remaining = size() - src;
+        Offset offset = size();
+
+        while (remaining > 0) {
+            offset -= std::min(remaining, buffer_size);
+            to_read = std::min(remaining, buffer_size);
+
+            wrapped_->seek(offset);
+            auto result = wrapped_->read(buffer, to_read);
+            if (result.is_error())
+                break;
+
+            wrapped_->seek(offset + delta);
+            result = wrapped_->write(buffer, *result);
+            if (result.is_error())
+                break;
+
+            remaining -= *result;
+        }
+    }
+
+    /* Shrink the file and move file content so that the
+     * content at src is shifted backward by 'delta'. */
+    void shrink(Offset src, int32_t delta) {
+        if (delta >= 0)  // Not a shrink.
+            return;
+
+        char buffer[buffer_size];
+        auto offset = src;
+
+        while (true) {
+            wrapped_->seek(offset);
+            auto result = wrapped_->read(buffer, buffer_size);
+            if (result.is_error())
+                break;
+
+            wrapped_->seek(offset + delta);
+            result = wrapped_->write(buffer, *result);
+
+            if (result.is_error() || *result < buffer_size)
+                break;
+
+            offset += *result;
+        }
+
+        // Delete the extra bytes at the end of the file.
+        wrapped_->truncate();
+    }
+
     BufferType* wrapped_{};
 
     /* Total number of lines in the buffer. */
@@ -316,7 +464,7 @@ class FileWrapper : public BufferWrapper<File, 64> {
     using Error = File::Error;
     static Result<std::unique_ptr<FileWrapper>> open(const std::filesystem::path& path) {
         auto fw = std::unique_ptr<FileWrapper>(new FileWrapper());
-        auto error = fw->file_.open(path);
+        auto error = fw->file_.open(path, /*read_only*/ false);
 
         if (error)
             return *error;
@@ -325,6 +473,23 @@ class FileWrapper : public BufferWrapper<File, 64> {
         return fw;
     }
 
+    /* Underlying file. */
+    File& file() { return file_; }
+
+    /* Swaps out the underlying file for the specified file.
+     * The swapped file is expected have the same contents.
+     * For copy-on-write scenario with a temp file. */
+    bool assume_file(const std::filesystem::path& path) {
+        File file;
+        auto error = file.open(path, /*read_only*/ false);
+
+        if (error)
+            return false;
+
+        file_ = std::move(file);
+        return true;
+    }
+
    private:
     FileWrapper() {}
     void initialize() {
diff --git a/firmware/application/ui_navigation.cpp b/firmware/application/ui_navigation.cpp
index 676222ba..cd2fc758 100644
--- a/firmware/application/ui_navigation.cpp
+++ b/firmware/application/ui_navigation.cpp
@@ -236,10 +236,8 @@ void SystemStatusView::refresh() {
 
     if (portapack::clock_manager.get_reference().source == ClockManager::ReferenceSource::External) {
         button_clock_status.set_bitmap(&bitmap_icon_clk_ext);
-        //		button_bias_tee.set_foreground(ui::Color::green());   Typo?
     } else {
         button_clock_status.set_bitmap(&bitmap_icon_clk_int);
-        //		button_bias_tee.set_foreground(ui::Color::green());
     }
 
     if (portapack::persistent_memory::clkout_enabled()) {
@@ -420,7 +418,7 @@ View* NavigationView::push_view(std::unique_ptr<View> new_view) {
     free_view();
 
     const auto p = new_view.get();
-    view_stack.emplace_back(std::move(new_view));
+    view_stack.emplace_back(ViewState{std::move(new_view), {}});
 
     update_view();
 
@@ -428,34 +426,14 @@ View* NavigationView::push_view(std::unique_ptr<View> new_view) {
 }
 
 void NavigationView::pop() {
-    if (view() == modal_view) {
-        modal_view = nullptr;
-    }
-
-    // Can't pop last item from stack.
-    if (view_stack.size() > 1) {
-        free_view();
-
-        view_stack.pop_back();
-
-        update_view();
-    }
+    pop(true);
 }
 
 void NavigationView::pop_modal() {
-    if (view() == modal_view) {
-        modal_view = nullptr;
-    }
-
-    // Pop modal view + underlying app view
-    if (view_stack.size() > 2) {
-        free_view();
-        view_stack.pop_back();
-        free_view();
-        view_stack.pop_back();
-
-        update_view();
-    }
+    // Pop modal view + underlying app view.
+    // TODO: this shouldn't be necessary.
+    pop(false);
+    pop(true);
 }
 
 void NavigationView::display_modal(
@@ -475,12 +453,31 @@ void NavigationView::display_modal(
     }
 }
 
+void NavigationView::pop(bool update) {
+    if (view() == modal_view) {
+        modal_view = nullptr;
+    }
+
+    // Can't pop last item from stack.
+    if (view_stack.size() > 1) {
+        auto on_pop = view_stack.back().on_pop;
+
+        free_view();
+        view_stack.pop_back();
+
+        if (update)
+            update_view();
+
+        if (on_pop) on_pop();
+    }
+}
+
 void NavigationView::free_view() {
     remove_child(view());
 }
 
 void NavigationView::update_view() {
-    const auto new_view = view_stack.back().get();
+    const auto new_view = view_stack.back().view.get();
 
     add_child(new_view);
     new_view->set_parent_rect({{0, 0}, size()});
@@ -503,6 +500,18 @@ void NavigationView::focus() {
     }
 }
 
+bool NavigationView::set_on_pop(std::function<void()> on_pop) {
+    if (view_stack.size() <= 1)
+        return false;
+
+    auto& top = view_stack.back();
+    if (top.on_pop)
+        return false;
+
+    top.on_pop = on_pop;
+    return true;
+}
+
 /* ReceiversMenuView *****************************************************/
 
 ReceiversMenuView::ReceiversMenuView(NavigationView& nav) {
diff --git a/firmware/application/ui_navigation.hpp b/firmware/application/ui_navigation.hpp
index ba7313fb..4d3b8a25 100644
--- a/firmware/application/ui_navigation.hpp
+++ b/firmware/application/ui_navigation.hpp
@@ -75,9 +75,11 @@ class NavigationView : public View {
 
     // Pushes a new view under the current on the stack so the current view returns into this new one.
     template <class T, class... Args>
-    void push_under_current(Args&&... args) {
+    T* push_under_current(Args&&... args) {
         auto new_view = std::unique_ptr<View>(new T(*this, std::forward<Args>(args)...));
-        view_stack.insert(view_stack.end() - 1, std::move(new_view));
+        auto new_view_ptr = new_view.get();
+        view_stack.insert(view_stack.end() - 1, ViewState{std::move(new_view), {}});
+        return reinterpret_cast<T*>(new_view_ptr);
     }
 
     template <class T, class... Args>
@@ -97,12 +99,22 @@ class NavigationView : public View {
 
     void focus() override;
 
+    /* Sets the 'on_pop' handler for the current view.
+     * Returns true if the handler was bound successfully. */
+    bool set_on_pop(std::function<void()> on_pop);
+
    private:
-    std::vector<std::unique_ptr<View>> view_stack{};
+    struct ViewState {
+        std::unique_ptr<View> view;
+        std::function<void()> on_pop;
+    };
+
+    std::vector<ViewState> view_stack{};
     Widget* modal_view{nullptr};
 
     Widget* view() const;
 
+    void pop(bool update);
     void free_view();
     void update_view();
     View* push_view(std::unique_ptr<View> new_view);
diff --git a/firmware/common/optional.hpp b/firmware/common/optional.hpp
index 7ff4c6d8..d382b957 100644
--- a/firmware/common/optional.hpp
+++ b/firmware/common/optional.hpp
@@ -43,10 +43,8 @@ class Optional {
     const T& value() const& { return value_; }
     const T& operator*() const& { return value_; }
 
-    T&& value() && { return value_; }
-    T&& operator*() && { return value_; }
-    const T&& value() const&& { return value_; }
-    const T&& operator*() const&& { return value_; }
+    T&& value() && { return std::move(value_); }
+    T&& operator*() && { return std::move(value_); }
 
     T* operator->() { return &value_; }
     const T* operator->() const { return &value_; }
diff --git a/firmware/common/ui_painter.cpp b/firmware/common/ui_painter.cpp
index fa3d7da6..b6edd8e8 100644
--- a/firmware/common/ui_painter.cpp
+++ b/firmware/common/ui_painter.cpp
@@ -35,18 +35,27 @@ Style Style::invert() const {
         .foreground = background};
 }
 
-int Painter::draw_char(const Point p, const Style& style, const char c) {
+int Painter::draw_char(Point p, const Style& style, char c) {
     const auto glyph = style.font.glyph(c);
     display.draw_glyph(p, glyph, style.foreground, style.background);
     return glyph.advance().x();
 }
 
-int Painter::draw_string(Point p, const Font& font, const Color foreground, const Color background, const std::string& text) {
+int Painter::draw_string(Point p, const Style& style, std::string_view text) {
+    return draw_string(p, style.font, style.foreground, style.background, text);
+}
+
+int Painter::draw_string(
+    Point p,
+    const Font& font,
+    Color foreground,
+    Color background,
+    std::string_view text) {
     bool escape = false;
     size_t width = 0;
     Color pen = foreground;
 
-    for (const auto c : text) {
+    for (auto c : text) {
         if (escape) {
             if (c <= 15)
                 pen = term_colors[c & 15];
@@ -65,48 +74,45 @@ int Painter::draw_string(Point p, const Font& font, const Color foreground, cons
             }
         }
     }
+
     return width;
 }
 
-int Painter::draw_string(Point p, const Style& style, const std::string& text) {
-    return draw_string(p, style.font, style.foreground, style.background, text);
-}
-
-void Painter::draw_bitmap(const Point p, const Bitmap& bitmap, const Color foreground, const Color background) {
+void Painter::draw_bitmap(Point p, const Bitmap& bitmap, Color foreground, Color background) {
     display.draw_bitmap(p, bitmap.size, bitmap.data, foreground, background);
 }
 
-void Painter::draw_hline(Point p, int width, const Color c) {
+void Painter::draw_hline(Point p, int width, Color c) {
     display.fill_rectangle({p, {width, 1}}, c);
 }
 
-void Painter::draw_vline(Point p, int height, const Color c) {
+void Painter::draw_vline(Point p, int height, Color c) {
     display.fill_rectangle({p, {1, height}}, c);
 }
 
-void Painter::draw_rectangle(const Rect r, const Color c) {
+void Painter::draw_rectangle(Rect r, Color c) {
     draw_hline(r.location(), r.width(), c);
     draw_vline({r.left(), r.top() + 1}, r.height() - 2, c);
     draw_vline({r.left() + r.width() - 1, r.top() + 1}, r.height() - 2, c);
     draw_hline({r.left(), r.top() + r.height() - 1}, r.width(), c);
 }
 
-void Painter::fill_rectangle(const Rect r, const Color c) {
+void Painter::fill_rectangle(Rect r, Color c) {
     display.fill_rectangle(r, c);
 }
 
-void Painter::fill_rectangle_unrolled8(const Rect r, const Color c) {
+void Painter::fill_rectangle_unrolled8(Rect r, Color c) {
     display.fill_rectangle_unrolled8(r, c);
 }
 
-void Painter::paint_widget_tree(Widget* const w) {
+void Painter::paint_widget_tree(Widget* w) {
     if (ui::is_dirty()) {
         paint_widget(w);
         ui::dirty_clear();
     }
 }
 
-void Painter::paint_widget(Widget* const w) {
+void Painter::paint_widget(Widget* w) {
     if (w->hidden()) {
         // Mark widget (and all children) as invisible.
         w->visible(false);
diff --git a/firmware/common/ui_painter.hpp b/firmware/common/ui_painter.hpp
index 24f7835e..c71c2303 100644
--- a/firmware/common/ui_painter.hpp
+++ b/firmware/common/ui_painter.hpp
@@ -25,7 +25,7 @@
 #include "ui.hpp"
 #include "ui_text.hpp"
 
-#include <string>
+#include <string_view>
 
 namespace ui {
 
@@ -46,24 +46,24 @@ class Painter {
     Painter(const Painter&) = delete;
     Painter(Painter&&) = delete;
 
-    int draw_char(const Point p, const Style& style, const char c);
+    int draw_char(Point p, const Style& style, char c);
 
-    int draw_string(Point p, const Font& font, const Color foreground, const Color background, const std::string& text);
-    int draw_string(Point p, const Style& style, const std::string& text);
+    int draw_string(Point p, const Style& style, std::string_view text);
+    int draw_string(Point p, const Font& font, Color foreground, Color background, std::string_view text);
 
-    void draw_bitmap(const Point p, const Bitmap& bitmap, const Color background, const Color foreground);
+    void draw_bitmap(Point p, const Bitmap& bitmap, Color background, Color foreground);
 
-    void draw_rectangle(const Rect r, const Color c);
-    void fill_rectangle(const Rect r, const Color c);
-    void fill_rectangle_unrolled8(const Rect r, const Color c);
+    void draw_rectangle(Rect r, Color c);
+    void fill_rectangle(Rect r, Color c);
+    void fill_rectangle_unrolled8(Rect r, Color c);
 
-    void paint_widget_tree(Widget* const w);
+    void paint_widget_tree(Widget* w);
 
-    void draw_hline(Point p, int width, const Color c);
-    void draw_vline(Point p, int height, const Color c);
+    void draw_hline(Point p, int width, Color c);
+    void draw_vline(Point p, int height, Color c);
 
    private:
-    void paint_widget(Widget* const w);
+    void paint_widget(Widget* w);
 };
 
 } /* namespace ui */
diff --git a/firmware/test/application/test_file_wrapper.cpp b/firmware/test/application/test_file_wrapper.cpp
index dd0b22b6..98376097 100644
--- a/firmware/test/application/test_file_wrapper.cpp
+++ b/firmware/test/application/test_file_wrapper.cpp
@@ -1,5 +1,5 @@
 /*
- * Copyright (C) 2023
+ * Copyright (C) 2023 Kyle Reed
  *
  * This file is part of PortaPack.
  *
@@ -42,14 +42,23 @@ class MockFile {
     Size size() { return data_.size(); }
 
     Result<Offset> seek(uint32_t offset) {
-        if (offset >= size())
+        if ((int32_t)offset < 0)
             return {static_cast<Error>(FR_BAD_SEEK)};
 
         auto previous = offset_;
+
+        if (offset > size())
+            data_.resize(offset);
+
         offset_ = offset;
         return previous;
     }
 
+    Result<Offset> truncate() {
+        data_.resize(offset_);
+        return offset_;
+    }
+
     Result<Size> read(void* data, Size bytes_to_read) {
         if (offset_ + bytes_to_read > size())
             bytes_to_read = size() - offset_;
@@ -62,6 +71,20 @@ class MockFile {
         return bytes_to_read;
     }
 
+    Result<Size> write(const void* data, Size bytes_to_write) {
+        auto new_offset = offset_ + bytes_to_write;
+        if (new_offset >= size())
+            data_.resize(new_offset);
+
+        memcpy(&data_[offset_], data, bytes_to_write);
+        offset_ = new_offset;
+        return bytes_to_write;
+    }
+
+    Optional<Error> sync() {
+        return {};
+    }
+
     std::string data_;
     uint32_t offset_{0};
 };
@@ -89,11 +112,19 @@ TEST_SUITE("Test MockFile") {
     SCENARIO("File seek") {
         GIVEN("Valid file") {
             MockFile f{"abc\ndef"};
+            auto init_size = f.size();
+
+            WHEN("seek()") {
+                f.seek(4);
+                THEN("offset_ should be updated.") {
+                    CHECK_EQ(f.offset_, 4);
+                }
+            }
 
             WHEN("seek() negative offset") {
                 auto r = f.seek(-1);
 
-                THEN("Result should be bad_seek") {
+                THEN("Result should be bad_seek.") {
                     CHECK(r.is_error());
                     CHECK_EQ(r.error().code(), FR_BAD_SEEK);
                 }
@@ -102,25 +133,25 @@ TEST_SUITE("Test MockFile") {
             WHEN("seek() offset is size()") {
                 auto r = f.seek(f.size());
 
-                THEN("Result should be bad_seek") {
-                    CHECK(r.is_error());
-                    CHECK_EQ(r.error().code(), FR_BAD_SEEK);
+                THEN("File should not grow.") {
+                    CHECK(r.is_ok());
+                    CHECK_EQ(f.size(), init_size);
                 }
             }
 
             WHEN("seek() offset > size()") {
                 auto r = f.seek(f.size() + 1);
 
-                THEN("Result should be bad_seek") {
-                    CHECK(r.is_error());
-                    CHECK_EQ(r.error().code(), FR_BAD_SEEK);
+                THEN("File should grow.") {
+                    CHECK(r.is_ok());
+                    CHECK_EQ(f.size(), init_size + 1);
                 }
             }
 
             WHEN("seek() offset < size()") {
                 auto r = f.seek(1);
 
-                THEN("Result should be ok") {
+                THEN("Result should be ok.") {
                     CHECK(r.is_ok());
                 }
 
@@ -185,6 +216,72 @@ TEST_SUITE("Test MockFile") {
             }
         }
     }
+
+    SCENARIO("File write") {
+        GIVEN("Valid file") {
+            MockFile f{"abc\ndef"};
+
+            WHEN("Writing over existing region") {
+                f.write("xyz", 3);
+
+                THEN("It should overwrite") {
+                    CHECK_EQ(f.data_, "xyz\ndef");
+                }
+            }
+
+            WHEN("Writing over past end") {
+                f.seek(f.size());
+                f.write("xyz", 3);
+
+                THEN("It should extend file and write") {
+                    CHECK_EQ(f.size(), 10);
+                    CHECK_EQ(f.data_, "abc\ndefxyz");
+                }
+            }
+        }
+    }
+
+    // This scenario was tested on device.
+    SCENARIO("File truncate") {
+        GIVEN("Valid file") {
+            MockFile f{"hello world"};
+
+            WHEN("truncating at offset 5") {
+                f.seek(5);
+                f.truncate();
+
+                THEN("resulting file should be 'hello'.") {
+                    CHECK_EQ(f.size(), 5);
+                    CHECK_EQ(f.data_, "hello");
+                }
+            }
+        }
+    }
+
+    SCENARIO("File truncate") {
+        GIVEN("Valid file") {
+            MockFile f{"abc\ndef"};
+            auto init_size = f.size();
+
+            WHEN("R/W pointer at end") {
+                f.seek(f.size());
+                f.truncate();
+
+                THEN("It should not change size.") {
+                    CHECK_EQ(f.size(), init_size);
+                }
+            }
+
+            WHEN("R/W pointer in middle") {
+                f.seek(3);
+                f.truncate();
+
+                THEN("It should change size.") {
+                    CHECK_EQ(f.size(), 3);
+                }
+            }
+        }
+    }
 }
 
 TEST_SUITE_BEGIN("Test BufferWrapper");
@@ -343,4 +440,259 @@ SCENARIO("Reading with cache miss.") {
     }
 }
 
+SCENARIO("Replace range of same size.") {
+    GIVEN("A file with lines") {
+        MockFile f{"abc\ndef"};
+        auto w = wrap_buffer(f);
+        auto init_line_count = w.line_count();
+        auto init_size = w.size();
+
+        WHEN("Replacing range without changing size") {
+            w.replace_range({0, 3}, "xyz");
+
+            CHECK_EQ("xyz\ndef", f.data_);
+
+            THEN("size should not change.") {
+                CHECK_EQ(w.size(), init_size);
+            }
+
+            THEN("text should be replaced.") {
+                auto str = w.get_text(0, 0, 10);
+                REQUIRE(str);
+                CHECK_EQ(*str, "xyz\n");
+            }
+        }
+    }
+}
+
+SCENARIO("Replace range that increases size.") {
+    GIVEN("A file with lines") {
+        MockFile f{"abc\ndef"};
+        auto w = wrap_buffer(f);
+        auto init_line_count = w.line_count();
+        auto init_size = w.size();
+
+        WHEN("Replacing range with larger size") {
+            w.replace_range({0, 3}, "wxyz");
+
+            CHECK_EQ(f.data_, "wxyz\ndef");
+
+            THEN("size should be increased.") {
+                CHECK_EQ(w.size(), init_size + 1);
+            }
+
+            THEN("text should be replaced.") {
+                auto str = w.get_text(0, 0, 10);
+                REQUIRE(str);
+                CHECK_EQ(*str, "wxyz\n");
+            }
+
+            THEN("following text should not be modified.") {
+                auto str = w.get_text(1, 0, 10);
+                REQUIRE(str);
+                CHECK_EQ(*str, "def");
+            }
+        }
+    }
+
+    GIVEN("A file larger than internal buffer_size (512)") {
+        std::string content = std::string(599, 'a');
+        content.push_back('x');
+        MockFile f{content};
+
+        auto w = wrap_buffer(f);
+        auto init_line_count = w.line_count();
+        auto init_size = w.size();
+
+        WHEN("Replacing range with larger size") {
+            w.replace_range({0, 2}, "bbb");
+
+            THEN("size should be increased.") {
+                CHECK_EQ(w.size(), init_size + 1);
+            }
+
+            THEN("text should be replaced.") {
+                auto str = w.get_text(0, 0, 10);
+                REQUIRE(str);
+                CHECK_EQ(*str, "bbbaaaaaaa");
+            }
+
+            THEN("end text should be preserved.") {
+                CHECK_EQ(f.data_.back(), 'x');
+            }
+        }
+    }
+}
+
+SCENARIO("Replace range that decreases size.") {
+    GIVEN("A file with lines") {
+        MockFile f{"abc\ndef"};
+        auto w = wrap_buffer(f);
+        auto init_line_count = w.line_count();
+        auto init_size = w.size();
+
+        WHEN("Replacing range with smaller size") {
+            w.replace_range({0, 3}, "yz");
+
+            CHECK_EQ(f.data_, "yz\ndef");
+
+            THEN("size should be decreased.") {
+                CHECK_EQ(w.size(), init_size - 1);
+            }
+
+            THEN("text should be replaced.") {
+                auto str = w.get_text(0, 0, 10);
+                REQUIRE(str);
+                CHECK_EQ(*str, "yz\n");
+            }
+
+            THEN("following text should not be modified.") {
+                auto str = w.get_text(1, 0, 10);
+                REQUIRE(str);
+                CHECK_EQ(*str, "def");
+            }
+        }
+    }
+
+    GIVEN("A file larger than internal buffer_size (512)") {
+        std::string content = std::string(599, 'a');
+        content.push_back('x');
+        MockFile f{content};
+
+        auto w = wrap_buffer(f);
+        auto init_line_count = w.line_count();
+        auto init_size = w.size();
+
+        WHEN("Replacing range with smaller size") {
+            w.replace_range({0, 10}, "b");
+
+            THEN("size should be decreased.") {
+                CHECK_EQ(w.size(), init_size - 9);
+            }
+
+            THEN("text should be replaced.") {
+                auto str = w.get_text(0, 0, 10);
+                REQUIRE(str);
+                CHECK_EQ(*str, "baaaaaaaaa");
+            }
+
+            THEN("end should be moved toward front.") {
+                CHECK_EQ(f.data_.back(), 'x');
+            }
+        }
+    }
+}
+
+SCENARIO("Insert line.") {
+    GIVEN("A file with lines") {
+        MockFile f{"abc\ndef\nghi\n"};
+        auto w = wrap_buffer(f);
+        auto init_line_count = w.line_count();
+        auto init_size = w.size();
+
+        WHEN("Inserting before the first line") {
+            w.insert_line(0);
+
+            THEN("should increment line count and size.") {
+                CHECK_EQ(w.line_count(), init_line_count + 1);
+                CHECK_EQ(w.size(), init_size + 1);
+            }
+
+            THEN("should insert empty line content.") {
+                auto str = w.get_text(0, 0, 10);
+                CHECK_EQ(*str, "\n");
+            }
+
+            THEN("first line should be pushed down.") {
+                auto str = w.get_text(1, 0, 10);
+                CHECK_EQ(*str, "abc\n");
+            }
+        }
+
+        WHEN("Inserting before the last line") {
+            w.insert_line(2);
+
+            THEN("should increment line count and size.") {
+                CHECK_EQ(w.line_count(), init_line_count + 1);
+                CHECK_EQ(w.size(), init_size + 1);
+            }
+
+            THEN("should insert empty line content.") {
+                auto str = w.get_text(2, 0, 10);
+                CHECK_EQ(*str, "\n");
+            }
+
+            THEN("last line should be pushed down.") {
+                auto str = w.get_text(3, 0, 10);
+                CHECK_EQ(*str, "ghi\n");
+            }
+        }
+
+        WHEN("Inserting after the last line") {
+            w.insert_line(w.line_count());
+
+            THEN("should increment line count and size.") {
+                CHECK_EQ(w.line_count(), init_line_count + 1);
+                CHECK_EQ(w.size(), init_size + 1);
+            }
+
+            THEN("should insert empty line content.") {
+                auto str = w.get_text(3, 0, 10);
+                CHECK_EQ(*str, "\n");
+            }
+        }
+    }
+}
+
+SCENARIO("Delete line.") {
+    GIVEN("A file with lines") {
+        MockFile f{"abc\ndef\nghi"};
+        auto w = wrap_buffer(f);
+        auto init_line_count = w.line_count();
+        auto init_size = w.size();
+
+        WHEN("Deleting the first line") {
+            w.delete_line(0);
+
+            THEN("should decrement line count and size.") {
+                CHECK_EQ(w.line_count(), init_line_count - 1);
+                CHECK_EQ(w.size(), init_size - 4);
+            }
+
+            THEN("should remove first line content.") {
+                auto str = w.get_text(0, 0, 10);
+                CHECK_EQ(*str, "def\n");
+            }
+        }
+
+        WHEN("Deleting the middle line") {
+            w.delete_line(1);
+
+            THEN("should decrement line count and size.") {
+                CHECK_EQ(w.line_count(), init_line_count - 1);
+                CHECK_EQ(w.size(), init_size - 4);
+            }
+
+            THEN("should remove middle line content.") {
+                auto str = w.get_text(1, 0, 10);
+                CHECK_EQ(*str, "ghi");
+            }
+        }
+
+        WHEN("Deleting the last line") {
+            w.delete_line(2);
+
+            THEN("should decrement line count and size.") {
+                CHECK_EQ(w.line_count(), init_line_count - 1);
+                CHECK_EQ(w.size(), init_size - 3);
+            }
+
+            THEN("should remove last line content.") {
+                auto str = w.get_text(2, 0, 10);
+                CHECK(!str);
+            }
+        }
+    }
+}
+
 TEST_SUITE_END();
\ No newline at end of file