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>
This commit is contained in:
Kyle Reed
2023-06-01 15:45:55 -07:00
committed by GitHub
parent 69011754c9
commit 8d7fdeb633
11 changed files with 847 additions and 148 deletions

View File

@@ -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
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

View File

@@ -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) */

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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() {

View File

@@ -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) {

View File

@@ -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);