From 98f3bf151f6506a1709ba469e1271d1ab482f33f Mon Sep 17 00:00:00 2001 From: Kyle Reed Date: Mon, 22 May 2023 13:08:59 -0700 Subject: [PATCH] Lazy line caching for Notepad (#1042) * easier 'now', start adding text editor * Adding scrolling to notepad * Better scrolling * Better scrolling, off-by-1 bugs * MVP fit and finish * Add tiny font and use in Notepad * Font tweaking, tiny font cursor * Fix warning * Format changed files * WIP No file limit * WIP - adding CircularBuffer type * WIP Caching * add unit test for circular_buffer * WIP still have a bug when moving cache forward * Finish lazy line caching --------- Co-authored-by: kallanreed --- firmware/application/apps/ui_text_editor.cpp | 389 ++++++++++++------ firmware/application/apps/ui_text_editor.hpp | 103 +++-- firmware/application/file.cpp | 4 +- firmware/application/file.hpp | 11 +- firmware/application/freqman.cpp | 1 - firmware/application/ui/ui_font_fixed_5x8.cpp | 12 +- firmware/common/circular_buffer.hpp | 120 ++++++ firmware/common/optional.hpp | 23 +- firmware/test/application/CMakeLists.txt | 1 + .../test/application/test_circular_buffer.cpp | 252 ++++++++++++ 10 files changed, 741 insertions(+), 175 deletions(-) create mode 100644 firmware/common/circular_buffer.hpp create mode 100644 firmware/test/application/test_circular_buffer.cpp diff --git a/firmware/application/apps/ui_text_editor.cpp b/firmware/application/apps/ui_text_editor.cpp index 7e291c82..83c34bc1 100644 --- a/firmware/application/apps/ui_text_editor.cpp +++ b/firmware/application/apps/ui_text_editor.cpp @@ -21,6 +21,8 @@ #include "ui_fileman.hpp" #include "ui_text_editor.hpp" + +#include "log_file.hpp" #include "string_format.hpp" using namespace portapack; @@ -31,19 +33,240 @@ template T mid(const T& val1, const T& val2, const T& val3) { return std::max(val1, std::min(val2, val3)); } + +/*void log(const std::string& msg) { + LogFile log{}; + log.append("LOGS/Notepad.txt"); + log.write_entry(msg); +}*/ } // namespace namespace ui { +/* FileWrapper ******************************************************/ + +FileWrapper::FileWrapper() { +} + +Optional FileWrapper::open(const fs::path& path) { + file_ = File(); + auto result = file_.open(path); + + if (!result.is_valid()) // No error + initialize(); + + return result; +} + +std::string FileWrapper::get_text(Offset line, Offset col, Offset length) { + // TODO: better way to return errors. + auto range = line_range(line); + int32_t to_read = length; + + if (!range.is_valid()) + return "[UNCACHED LINE]"; + + // Don't read past end of line. + if (range->start + col + to_read >= range->end) + to_read = range->end - col - range->start; + + if (to_read <= 0) + return {}; + + return read(range->start + col, to_read); +} + +Optional FileWrapper::line_range(Line line) { + ensure_cached(line); + + auto offset = offset_for_line(line); + if (!offset.is_valid()) + return {}; + + auto start = *offset == 0 ? start_offset_ : (newlines_[*offset - 1] + 1); + auto end = newlines_[*offset] + 1; + + return {Range{start, end}}; +} + +FileWrapper::Offset FileWrapper::line_length(Line line) { + auto range = line_range(line); + + if (range.is_valid()) + return range->end - range->start; + + return 0; +} + +void FileWrapper::initialize() { + start_offset_ = 0; + start_line_ = 0; + line_count_ = 0; + newlines_.clear(); + line_ending_ = LineEnding::LF; + + Offset offset = 0; + auto result = next_newline(offset); + + while (result.is_valid()) { + ++line_count_; + if (newlines_.size() < max_newlines) + newlines_.push_back(*result); + offset = *result + 1; + result = next_newline(offset); + } +} + +std::string FileWrapper::read(Offset offset, Offset length) { + // TODO: better way to return errors. + if (offset + length > file_.size()) + return {"[BAD OFFSET]"}; + + std::string buffer(length, '\0'); + file_.seek(offset); + + auto result = file_.read(&buffer[0], length); + if (result.is_ok()) + buffer.resize(*result); + else + return result.error().what(); + + return buffer; +} + +Optional FileWrapper::offset_for_line(Line line) const { + if (line >= line_count_) + return {}; + + Offset actual = line - start_line_; + if (actual < newlines_.size()) // NB: underflow wrap. + return {actual}; + + return {}; +} + +void FileWrapper::ensure_cached(Line line) { + if (line >= line_count_) + return; + + auto result = offset_for_line(line); + if (result.is_valid()) + return; + + if (line < start_line_) { + while (line < start_line_ && start_offset_ >= 2) { + // start_offset_ - 1 should be a newline. Need to + // find the new value for start_offset_. start_line_ + // has to be > 0 to get into this block so there should + // always be one newline before start_offset_. + auto offset = previous_newline(start_offset_ - 2); + newlines_.push_front(start_offset_ - 1); + + if (!offset.is_valid()) { + // Must be at beginning. + start_line_ = 0; + start_offset_ = 0; + } else { + // Found an previous newline, the new start_line_ + // starts at the newline offset + 1. + start_line_--; + start_offset_ = *offset + 1; + } + } + } else { + while (line >= start_line_ + newlines_.size()) { + auto offset = next_newline(newlines_.back() + 1); + if (offset.is_valid()) { + start_line_++; + start_offset_ = newlines_.front() + 1; + newlines_.push_back(*offset); + } /* else at the EOF. */ + } + } +} + +Optional FileWrapper::previous_newline(Offset start) { + constexpr size_t buffer_size = 128; + char buffer[buffer_size]; + Offset offset = start; + auto to_read = buffer_size; + + do { + if (offset < to_read) { + // NB: Char at 'offset' was read in the previous iteration. + to_read = offset; + offset = 0; + } else + offset -= to_read; + + file_.seek(offset); + + auto result = file_.read(buffer, to_read); + if (result.is_error()) + break; + + // Find newlines in the buffer backwards. + for (int32_t i = *result - 1; i >= 0; --i) { + switch (buffer[i]) { + case '\n': + return {offset + i}; + } + } + + if (offset == 0) + break; + + } while (true); + + return {}; // Didn't find one. +} + +Optional FileWrapper::next_newline(Offset start) { + constexpr size_t buffer_size = 128; + char buffer[buffer_size]; + Offset offset = start; + + // EOF, nothing to do. + if (start >= size()) + return {}; + + file_.seek(offset); + + while (true) { + auto result = file_.read(buffer, buffer_size); + if (result.is_error()) + return {}; + + // Find newlines in the buffer. + for (Offset i = 0; i < *result; ++i) { + switch (buffer[i]) { + case '\n': + return {offset + i}; + } + } + + offset += *result; + + if (*result < buffer_size) + break; + } + + // Fake a newline at the end for consistency. + return {offset}; +} + +/* TextEditorView ***************************************************/ + TextEditorView::TextEditorView(NavigationView& nav) : nav_{nav} { add_children( - {&button_open, - &text_position}); + { + &button_open, + &text_position, + &text_size, + }); set_focusable(true); - // log_.append("LOGS/NOTEPAD.TXT"); - button_open.on_select = [this](Button&) { auto open_view = nav_.push(".TXT"); open_view->on_changed = [this](std::filesystem::path path) { @@ -54,6 +277,7 @@ TextEditorView::TextEditorView(NavigationView& nav) void TextEditorView::on_focus() { refresh_ui(); + button_open.focus(); } void TextEditorView::paint(Painter& painter) { @@ -63,16 +287,19 @@ void TextEditorView::paint(Painter& painter) { if (!paint_state_.has_file) return; + // Move the viewport vertically. if (cursor_.line < first_line) first_line = cursor_.line; else if (cursor_.line >= first_line + max_line) first_line = cursor_.line - max_line + 1; + // Move the viewport horizontally. if (cursor_.col < first_col) first_col = cursor_.col; if (cursor_.col >= first_col + max_col) first_col = cursor_.col - max_col + 1; + // Viewport updated? Redraw text. if (first_line != paint_state_.first_line || first_col != paint_state_.first_col) { paint_state_.first_line = first_line; @@ -129,28 +356,27 @@ bool TextEditorView::on_encoder(EncoderEvent delta) { bool TextEditorView::apply_scrolling_constraints(int16_t delta_line, int16_t delta_col) { int32_t new_line = cursor_.line + delta_line; int32_t new_col = cursor_.col + delta_col; - - auto new_line_length = info_.line_length(new_line); + int32_t new_line_length = file_.line_length(new_line); if (new_col < 0) --new_line; - else if (new_col > new_line_length && delta_line == 0) { - // Only want to wrap if moving horizontally. + else if (new_col >= new_line_length && delta_line == 0) { + // Only wrap if moving horizontally. new_col = 0; ++new_line; } - if (new_line < 0 || (uint32_t)new_line >= info_.line_count()) + if (new_line < 0 || (uint32_t)new_line >= file_.line_count()) return false; - new_line_length = info_.line_length(new_line); + new_line_length = file_.line_length(new_line); // TODO: don't wrap with encoder? // Wrap or clamp column. if (new_line_length == 0) new_col = 0; - else if (new_col > new_line_length || new_col < 0) - new_col = new_line_length; + else if (new_col >= new_line_length || new_col < 0) + new_col = new_line_length - 1; cursor_.line = new_line; cursor_.col = new_col; @@ -161,134 +387,65 @@ bool TextEditorView::apply_scrolling_constraints(int16_t delta_line, int16_t del void TextEditorView::refresh_ui() { if (paint_state_.has_file) { text_position.set( - to_string_dec_uint(cursor_.col + 1) + ":" + - to_string_dec_uint(cursor_.line + 1) + "/" + - to_string_dec_uint(info_.line_count()) + - (info_.truncated ? "*" : "") + - " Size: " + - to_string_file_size(info_.size)); - focus(); + "Ln " + to_string_dec_uint(cursor_.line + 1) + + ", Col " + to_string_dec_uint(cursor_.col + 1)); + text_size.set( + "Lines:" + to_string_dec_uint(file_.line_count()) + + " (" + to_string_file_size(file_.size()) + ")"); } else { - button_open.focus(); + // if (!button_open.has_focus()) + // button_open.focus(); + text_position.set(""); + text_size.set(""); } set_dirty(); } -void TextEditorView::refresh_file_info() { - constexpr size_t buffer_size = 128; - char buffer[buffer_size]; - uint32_t base_offset = 0; - - file_.seek(0); - info_.newlines.clear(); - info_.line_ending = LineEnding::LF; - info_.size = file_.size(); - info_.truncated = false; - - while (true) { - auto result = file_.read(buffer, buffer_size); - if (result.is_error()) - break; // TODO: report error? - - // TODO: CRLF state machine for cross block. - for (uint32_t i = 0; i < result.value(); ++i) { - switch (buffer[i]) { - case '\n': - info_.newlines.push_back(base_offset + i); - } - } - - base_offset += result.value(); - - // Fake a newline at the end for consistency. - // Could check if there already is a trailing newline, but it doesn't hurt. - if (result.value() < buffer_size) { - info_.newlines.push_back(base_offset); - break; - } - - // HACK HACK: only show first 1000 lines for now. - if (info_.newlines.size() >= 1000) { - info_.truncated = true; - break; - } - } - - refresh_ui(); -} - void TextEditorView::open_file(const fs::path& path) { // TODO: need a temp backing file for edits. - auto result = file_.open(path); - paint_state_.has_file = !result.is_valid(); /* Has an error. */ - if (paint_state_.has_file) { - refresh_file_info(); - paint_state_.first_line = 0; - paint_state_.first_col = 0; - cursor_.line = 0; - cursor_.col = 0; - } else { - nav_.display_modal("Read Error", "Cannot open file:\n" + result.value().what()); - paint_state_.has_file = false; - } + if (result.is_valid()) + nav_.display_modal("Read Error", "Cannot open file:\n" + result->what()); + + paint_state_.has_file = !result.is_valid(); // Has an error. + paint_state_.first_line = 0; + paint_state_.first_col = 0; + cursor_.line = 0; + cursor_.col = 0; paint_state_.redraw_text = true; refresh_ui(); } -std::string TextEditorView::read(uint32_t offset, uint32_t length) { - if (offset >= info_.size) - return {"[BAD OFFSET]"}; - - std::string buffer(length + 1, '\0'); - file_.seek(offset); - - auto result = file_.read(&buffer[0], length); - if (result.is_ok()) - /* resize? */; - else - return result.error().what(); - - return buffer; -} - void TextEditorView::paint_text(Painter& painter, uint32_t line, uint16_t col) { // TODO: A line cache would use more memory but save a lot of IO. // Only the new lines/characters would need to be refetched. auto r = screen_rect(); - auto& lines = info_.newlines; - auto line_start = info_.line_start(line); // Draw the lines from the file - for (uint32_t i = 0; i < max_line && i < lines.size(); ++i) { - auto line_end = lines[line + i]; - int32_t read_length = max_col; + for (auto i = 0u; i < max_line; ++i) { + if (line + i >= file_.line_count()) + break; - // Don't read past end of the line. - if (line_start + col + (uint32_t)read_length > line_end) - read_length = line_end - col - line_start; + auto str = file_.get_text(line + i, col, max_col); - if (read_length > 0) + // Draw text. + if (str.length() > 0) painter.draw_string( {0, r.location().y() + (int)i * char_height}, - style_default, read(line_start + col, read_length)); + style_default, str); - // Erase empty line sectons. - if (read_length >= 0) { - int32_t clear_width = max_col - read_length; - if (clear_width > 0) - painter.fill_rectangle( - {(max_col - clear_width) * char_width, - r.location().y() + (int)i * char_height, - clear_width * char_width, char_height}, - style_default.background); - } - - line_start = lines[line + i] + 1 /* newline */; + // Clear empty line sections. + int32_t clear_width = max_col - str.length(); + if (clear_width > 0) + painter.fill_rectangle( + {(max_col - clear_width) * char_width, + r.location().y() + (int)i * char_height, + clear_width * char_width, char_height}, + style_default.background); } } @@ -305,9 +462,7 @@ void TextEditorView::paint_cursor(Painter& painter) { c); }; - // TOOD: bug where cursor doesn't clear at EOF. // TODO: XOR cursor? - // Clear old cursor. draw_cursor(paint_state_.line, paint_state_.col, style_default.background); draw_cursor(cursor_.line, cursor_.col, style_default.foreground); @@ -315,8 +470,8 @@ void TextEditorView::paint_cursor(Painter& painter) { paint_state_.col = cursor_.col; } -uint16_t TextEditorView::line_length() const { - return info_.line_length(cursor_.line); +uint16_t TextEditorView::line_length() { + return file_.line_length(cursor_.line); } } // namespace ui \ No newline at end of file diff --git a/firmware/application/apps/ui_text_editor.hpp b/firmware/application/apps/ui_text_editor.hpp index 865a183a..7c13bf9d 100644 --- a/firmware/application/apps/ui_text_editor.hpp +++ b/firmware/application/apps/ui_text_editor.hpp @@ -29,8 +29,9 @@ #include "ui_widget.hpp" //#include "ui_textentry.hpp" +#include "circular_buffer.hpp" #include "file.hpp" -#include "log_file.hpp" +#include "optional.hpp" #include #include @@ -47,44 +48,68 @@ enum class ScrollDirection : uint8_t { Horizontal }; -// TODO: RAM is _very_ limited. Need to -// rework this to not store every line. -// Should be able to get away with only -// abount one screen of lines so long as -// you can't scroll more than one screen -// at a time. -struct FileInfo { - /* Offsets of newlines. */ - std::vector newlines; - LineEnding line_ending; - File::Size size; - bool truncated; +/* Wraps a file and provides an API for accessing lines efficiently. */ +class FileWrapper { + public: + using Error = std::filesystem::filesystem_error; + using Offset = uint32_t; // TODO: make enums? + using Line = uint32_t; + using Column = uint32_t; + using Range = struct { + // Offset of the line start. + Offset start; + // Offset of one past the line end. + Offset end; + }; - uint32_t line_count() const { - return newlines.size(); - } + FileWrapper(); - uint32_t line_start(uint32_t line) const { - return line == 0 ? 0 : (newlines[line - 1] + 1); - } + /* Prevent copies. */ + FileWrapper(const FileWrapper&) = delete; + FileWrapper& operator=(const FileWrapper&) = delete; - uint16_t line_length(uint32_t line) const { - if (line >= line_count()) - return 0; + Optional open(const std::filesystem::path& path); + std::string get_text(Line line, Column col, Offset length); - auto start = line_start(line); - auto end = newlines[line]; - return end - start; - } + File::Size size() const { return file_.size(); } + uint32_t line_count() const { return line_count_; } + + Optional line_range(Line line); + Offset line_length(Line line); + + private: + /* Number of newline offsets to cache. */ + static constexpr Offset max_newlines = 64; + + void initialize(); + std::string read(Offset offset, Offset length = 30); + + /* Returns the offset into the newline cache if valid. */ + Optional offset_for_line(Line line) const; + + /* Ensure specified line is in the newline cache. */ + void ensure_cached(Line line); + + /* Helpers for finding the prev/next newline. */ + Optional previous_newline(Offset start); + Optional next_newline(Offset start); + + File file_{}; + + /* Total number of lines in the file. */ + Offset line_count_{0}; + + /* The offset and line of the newlines cache. */ + Offset start_offset_{0}; + Offset start_line_{0}; + + LineEnding line_ending_{LineEnding::LF}; + CircularBuffer newlines_{}; }; -/*class TextViewer : public Widget { -};*/ - class TextEditorView : public View { public: TextEditorView(NavigationView& nav); - // TextEditorView(NavigationView& nav, const std::filesystem::path& path); std::string title() const override { return "Notepad"; @@ -101,7 +126,6 @@ class TextEditorView : public View { static constexpr int8_t char_width = 5; static constexpr int8_t char_height = 8; - // TODO: should these be common somewhere? static constexpr Style style_default{ .font = font::fixed_5x8, .background = Color::black(), @@ -114,21 +138,17 @@ class TextEditorView : public View { int16_t delta_col); void refresh_ui(); - void refresh_file_info(); void open_file(const std::filesystem::path& path); - std::string read(uint32_t offset, uint32_t length = 30); void paint_text(Painter& painter, uint32_t line, uint16_t col); void paint_cursor(Painter& painter); // Gets the length of the current line. - uint16_t line_length() const; + uint16_t line_length(); NavigationView& nav_; - File file_{}; - FileInfo info_{}; - // LogFile log_{ }; + FileWrapper file_{}; struct { // Previous cursor state. @@ -148,11 +168,6 @@ class TextEditorView : public View { ScrollDirection dir{ScrollDirection::Vertical}; } cursor_{}; - /* 8px grid is 30 wide, 38 tall. */ - /* 16px font height or 19 rows. */ - /* Titlebar is 16px tall, so 18 rows left. */ - /* 240 x 320, (304 with titlebar) */ - // TODO: The scrollable view should be its own widget // otherwise control navigation doesn't work. @@ -161,6 +176,10 @@ class TextEditorView : public View { "Open"}; Text text_position{ + {0 * 8, 34 * 8, 24 * 8, 2 * 8}, + ""}; + + Text text_size{ {0 * 8, 36 * 8, 24 * 8, 2 * 8}, ""}; }; diff --git a/firmware/application/file.cpp b/firmware/application/file.cpp index fcfcdf4a..dc4ea85f 100644 --- a/firmware/application/file.cpp +++ b/firmware/application/file.cpp @@ -97,8 +97,8 @@ File::Result File::seek(const Offset new_position) { return {static_cast(old_position)}; } -File::Size File::size() { - return {static_cast(f_size(&f))}; +File::Size File::size() const { + return f_size(&f); } Optional File::write_line(const std::string& s) { diff --git a/firmware/application/file.hpp b/firmware/application/file.hpp index bdc69e97..b6023edc 100644 --- a/firmware/application/file.hpp +++ b/firmware/application/file.hpp @@ -311,6 +311,10 @@ class File { return value_; } + const T& operator*() const& { + return value_; + } + Error error() const { return error_; } @@ -339,6 +343,9 @@ class File { File(){}; ~File(); + File(File&&) = default; + File& operator=(File&&) = default; + /* Prevent copies */ File(const File&) = delete; File& operator=(const File&) = delete; @@ -352,8 +359,8 @@ class File { Result write(const void* const data, const Size bytes_to_write); Result seek(const uint64_t Offset); - Timestamp created_date(); - Size size(); + // Timestamp created_date() const; + Size size() const; template Result write(const std::array& data) { diff --git a/firmware/application/freqman.cpp b/firmware/application/freqman.cpp index 543d1ed9..1e1d3f24 100644 --- a/firmware/application/freqman.cpp +++ b/firmware/application/freqman.cpp @@ -315,7 +315,6 @@ bool save_freqman_file(std::string& file_stem, freqman_db& db) { auto& entry = db[n]; get_freq_string(entry, item_string); freqman_file.write_line(item_string); - delete &item_string; } delete_file(freq_file_path); rename_file(tmp_freq_file_path, freq_file_path); diff --git a/firmware/application/ui/ui_font_fixed_5x8.cpp b/firmware/application/ui/ui_font_fixed_5x8.cpp index 22fc5aa0..feb4aa95 100644 --- a/firmware/application/ui/ui_font_fixed_5x8.cpp +++ b/firmware/application/ui/ui_font_fixed_5x8.cpp @@ -338,11 +338,11 @@ const uint8_t fixed_5x8_glyph_data[] = { 0x02, // Index: 44 (0x2C) Char: 0x004C ('L') - 0x20, + 0x40, + 0x08, + 0x21, 0x84, - 0x10, - 0xC2, - 0x01, + 0x03, // Index: 45 (0x2D) Char: 0x004D ('M') 0xA0, @@ -654,8 +654,8 @@ const uint8_t fixed_5x8_glyph_data[] = { // Index: 89 (0x59) Char: 0x0079 ('y') 0x00, - 0x00, - 0xE5, + 0x80, + 0xE4, 0x90, 0x01, diff --git a/firmware/common/circular_buffer.hpp b/firmware/common/circular_buffer.hpp new file mode 100644 index 00000000..f455cd8e --- /dev/null +++ b/firmware/common/circular_buffer.hpp @@ -0,0 +1,120 @@ +/* + * Copyright (C) 2023 Kyle Reed + * + * This file is part of PortaPack. + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2, or (at your option) + * any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; see the file COPYING. If not, write to + * the Free Software Foundation, Inc., 51 Franklin Street, + * Boston, MA 02110-1301, USA. + */ + +#ifndef __CIRCULAR_BUFFER_H__ +#define __CIRCULAR_BUFFER_H__ + +#include // For size_t +#include + +/* Implements a fixed-size, circular buffer. + * NB: Holds Capacity - 1 items. + * There are no bounds checks on accessors so ensure there are + * items in the buffer before accessing front/back/operator[]. */ +template +class CircularBuffer { + public: + CircularBuffer() = default; + + CircularBuffer(const CircularBuffer&) = delete; + CircularBuffer(CircularBuffer&&) = delete; + CircularBuffer& operator=(const CircularBuffer&) = delete; + CircularBuffer& operator=(CircularBuffer&&) = delete; + + void push_front(T val) { + head_ = head_ > 0 ? head_ - 1 : last_index; + if (head_ == end_) + pop_back_internal(); + + data_[head_] = std::move(val); + } + + void pop_front() { + if (!empty()) + pop_front_internal(); + } + + void push_back(T val) { + data_[end_] = std::move(val); + + end_ = end_ < last_index ? end_ + 1 : 0; + if (head_ == end_) + pop_front_internal(); + } + + void pop_back() { + if (!empty()) + pop_back_internal(); + } + + T& operator[](size_t ix) & { + ix += head_; + if (ix >= Capacity) + ix -= Capacity; + return data_[ix]; + } + + const T& operator[](size_t ix) const& { + return const_cast(this)->operator[](ix); + } + + const T& front() const& { + return data_[head_]; + } + + const T& back() const& { + auto end = end_ > 0 ? end_ - 1 : last_index; + return data_[end]; + } + + size_t size() const& { + auto end = end_; + if (end < head_) + end += Capacity; + return end - head_; + } + + bool empty() const { + return head_ == end_; + } + + void clear() { + head_ = 0; + end_ = 0; + } + + private: + void pop_front_internal() { + head_ = head_ < last_index ? head_ + 1 : 0; + } + + void pop_back_internal() { + end_ = end_ > 0 ? end_ - 1 : last_index; + } + + static constexpr size_t last_index = Capacity - 1; + size_t head_{0}; + size_t end_{0}; + + T data_[Capacity]{}; +}; + +#endif /*__CIRCULAR_BUFFER_H__*/ \ No newline at end of file diff --git a/firmware/common/optional.hpp b/firmware/common/optional.hpp index c5561b3c..68712fc1 100644 --- a/firmware/common/optional.hpp +++ b/firmware/common/optional.hpp @@ -28,14 +28,27 @@ template class Optional { public: constexpr Optional() - : value_{}, valid_{false} {}; + : value_{}, valid_{false} {} constexpr Optional(const T& value) - : value_{value}, valid_{true} {}; + : value_{value}, valid_{true} {} constexpr Optional(T&& value) - : value_{std::move(value)}, valid_{true} {}; + : value_{std::move(value)}, valid_{true} {} - bool is_valid() const { return valid_; }; - const T& value() const { return value_; }; + bool is_valid() const { return valid_; } + + // TODO: Throw if not valid? + T& value() & { return value_; } + T& operator*() & { return value_; } + 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* operator->() { return &value_; } + const T* operator->() const { return &value_; } private: T value_; diff --git a/firmware/test/application/CMakeLists.txt b/firmware/test/application/CMakeLists.txt index 2ba26e9c..2968317b 100644 --- a/firmware/test/application/CMakeLists.txt +++ b/firmware/test/application/CMakeLists.txt @@ -35,6 +35,7 @@ set(CMAKE_CXX_COMPILER g++) add_executable(application_test EXCLUDE_FROM_ALL ${PROJECT_SOURCE_DIR}/main.cpp ${PROJECT_SOURCE_DIR}/test_basics.cpp + ${PROJECT_SOURCE_DIR}/test_circular_buffer.cpp ${PROJECT_SOURCE_DIR}/test_optional.cpp ) diff --git a/firmware/test/application/test_circular_buffer.cpp b/firmware/test/application/test_circular_buffer.cpp new file mode 100644 index 00000000..93dca15a --- /dev/null +++ b/firmware/test/application/test_circular_buffer.cpp @@ -0,0 +1,252 @@ +/* + * Copyright (C) 2023 + * + * This file is part of PortaPack. + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2, or (at your option) + * any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; see the file COPYING. If not, write to + * the Free Software Foundation, Inc., 51 Franklin Street, + * Boston, MA 02110-1301, USA. + */ + +#include "doctest.h" +#include "circular_buffer.hpp" + +TEST_SUITE_BEGIN("circular buffer"); + +SCENARIO("Items can be pushed and popped from front and back.") { + GIVEN("an empty buffer") { + CircularBuffer cb; + + REQUIRE(cb.empty()); + REQUIRE(cb.size() == 0); + + WHEN("push_back()") { + cb.push_back(1); + + THEN("size should increase") { + CHECK(cb.empty() == false); + CHECK(cb.size() == 1); + } + + cb.push_back(2); + + THEN("size should increase") { + CHECK(cb.empty() == false); + CHECK(cb.size() == 2); + } + + THEN("back() should be the last item pushed") { + CHECK(cb.back() == 2); + } + + THEN("front() should be the first item pushed") { + CHECK(cb.front() == 1); + } + } + + WHEN("push_front()") { + cb.push_front(3); + + THEN("size should increase") { + CHECK(cb.empty() == false); + CHECK(cb.size() == 1); + } + + cb.push_front(4); + + THEN("size should increase") { + CHECK(cb.empty() == false); + CHECK(cb.size() == 2); + } + + THEN("back() should be first item pushed") { + CHECK(cb.back() == 3); + } + + THEN("front() should be the last item pushed") { + CHECK(cb.front() == 4); + } + } + + WHEN("pop_back()") { + cb.pop_back(); + + THEN("size should not change") { + REQUIRE(cb.empty()); + REQUIRE(cb.size() == 0); + } + } + + WHEN("pop_front()") { + cb.pop_back(); + + THEN("size should not change") { + REQUIRE(cb.empty()); + REQUIRE(cb.size() == 0); + } + } + } + + GIVEN("a buffer with items") { + CircularBuffer cb; + cb.push_back(1); + cb.push_back(2); + cb.push_back(3); + + REQUIRE(!cb.empty()); + REQUIRE(cb.size() == 3); + REQUIRE(cb.front() == 1); + REQUIRE(cb.back() == 3); + + WHEN("pop_back()") { + cb.pop_back(); + + THEN("size should decrease") { + CHECK(cb.empty() == false); + CHECK(cb.size() == 2); + } + + THEN("back item should be removed") { + CHECK(cb.back() == 2); + } + + THEN("front item should be unchanged") { + CHECK(cb.front() == 1); + } + } + + WHEN("pop_front()") { + cb.pop_front(); + + THEN("size should decrease") { + CHECK(cb.empty() == false); + CHECK(cb.size() == 2); + } + + THEN("front item should be removed") { + CHECK(cb.front() == 2); + } + + THEN("back item should be unchanged") { + CHECK(cb.back() == 3); + } + } + + WHEN("clear()") { + cb.clear(); + + THEN("size should be empty") { + CHECK(cb.empty()); + CHECK(cb.size() == 0); + } + } + } + + GIVEN("a full buffer") { + CircularBuffer cb; + cb.push_back(1); + cb.push_back(2); + cb.push_back(3); + + REQUIRE(!cb.empty()); + REQUIRE(cb.size() == 3); + REQUIRE(cb.front() == 1); + REQUIRE(cb.back() == 3); + + WHEN("push_back()") { + cb.push_back(4); + + THEN("size should not be changed") { + CHECK(!cb.empty()); + CHECK(cb.size() == 3); + } + + THEN("front should be popped") { + CHECK(cb.front() == 2); + } + + THEN("back should be new value") { + CHECK(cb.back() == 4); + } + } + + WHEN("push_front()") { + cb.push_front(4); + + THEN("size should not be changed") { + CHECK(!cb.empty()); + CHECK(cb.size() == 3); + } + + THEN("front should be new value") { + CHECK(cb.front() == 4); + } + + THEN("back should be popped") { + CHECK(cb.back() == 2); + } + } + } +} + +SCENARIO("Items can be accessed randomly") { + GIVEN("buffer with items") { + CircularBuffer cb; + cb.push_front(1); + cb.push_back(2); + cb.push_back(3); + + WHEN("accessing items") { + THEN("front should be at 0") { + CHECK(cb.front() == cb[0]); + } + + THEN("last should be at size() - 1") { + CHECK(cb.back() == cb[cb.size() - 1]); + } + + THEN("all should be accessible") { + // Assumes values are in order. + for (size_t i = 0; i < cb.size(); i++) + CHECK(cb[i] == i + 1); + } + } + + WHEN("accessing items after push_front") { + cb.push_front(4); + + THEN("front should be at 0") { + CHECK(cb.front() == cb[0]); + } + + THEN("last should be at size() - 1") { + CHECK(cb.back() == cb[cb.size() - 1]); + } + } + + WHEN("accessing items after push_back") { + cb.push_back(4); + + THEN("front should be at 0") { + CHECK(cb.front() == cb[0]); + } + + THEN("last should be at size() - 1") { + CHECK(cb.back() == cb[cb.size() - 1]); + } + } + } +} + +TEST_SUITE_END(); \ No newline at end of file