Files
nocal/src/tui/EventEditor.cpp
Bernardo Magri 6c1f58ffb0 feat: recurrence rule editing in EventEditor and ordinal BYDAY support
Add recurrence_frequency, recurrence_interval, recurrence_count, and
recurrence_by_weekday editor fields. Frequency cycles with Space.
BYDAY input accepts comma-separated names and ordinal prefixes (1MO).

Extend RecurrenceRule::by_weekday from std::chrono::weekday to ByWeekday
{ordinal, day} struct. Parser accepts ordinal BYDAY (+1MO, -1FR).
Remove strict BY*/FREQ pairing constraint to match RFC 5545.
2026-07-23 18:45:50 +01:00

681 lines
27 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#include "nocal/tui/EventEditor.hpp"
#include "nocal/tui/unicode.hpp"
#include <algorithm>
#include <array>
#include <atomic>
#include <chrono>
#include <cstdint>
#include <cstdio>
#include <ctime>
#include <initializer_list>
#include <random>
#include <sstream>
#include <stdexcept>
#include <utility>
#include <vector>
namespace nocal::tui {
namespace {
constexpr std::size_t field_count = static_cast<std::size_t>(EditorField::count);
constexpr std::array<std::string_view, field_count> labels{
"Title", "Start date", "Start time", "End date", "End time",
"All day", "Location", "Notes",
"Repeat", "Interval", "Count", "On days",
};
std::size_t index_of(const EditorField field) noexcept
{
return static_cast<std::size_t>(field);
}
std::string make_uid()
{
static std::atomic<std::uint64_t> sequence{0};
static const std::uint64_t seed = [] {
std::random_device random;
const auto high = static_cast<std::uint64_t>(random()) << 32U;
return high ^ static_cast<std::uint64_t>(random());
}();
const auto now = std::chrono::duration_cast<std::chrono::nanoseconds>(
std::chrono::system_clock::now().time_since_epoch())
.count();
std::ostringstream stream;
stream << "nocal-" << std::hex << static_cast<std::uint64_t>(now) << '-'
<< (seed ^ sequence.fetch_add(1, std::memory_order_relaxed)) << "@local";
return stream.str();
}
std::string two_digits(const unsigned value)
{
std::array<char, 3> buffer{};
std::snprintf(buffer.data(), buffer.size(), "%02u", value);
return {buffer.data(), 2};
}
std::pair<unsigned, unsigned> local_hour_minute(const TimePoint point)
{
const auto time = Clock::to_time_t(point);
std::tm value{};
#if defined(_WIN32)
if (::localtime_s(&value, &time) != 0) {
throw std::runtime_error("could not convert event time");
}
#else
if (::localtime_r(&time, &value) == nullptr) {
throw std::runtime_error("could not convert event time");
}
#endif
return {static_cast<unsigned>(value.tm_hour), static_cast<unsigned>(value.tm_min)};
}
struct CivilMoment {
Date date;
unsigned hour;
unsigned minute;
};
CivilMoment event_civil_moment(const Event& event, const TimePoint point)
{
if (event.time_basis != TimeBasis::zoned || event.time_zone.empty()) {
const auto [hour, minute] = local_hour_minute(point);
return {local_date(point), hour, minute};
}
const auto* zone = std::chrono::locate_zone(event.time_zone);
const auto local = zone->to_local(point);
const auto local_day = std::chrono::floor<std::chrono::days>(local);
const std::chrono::year_month_day date{local_day};
const auto time = std::chrono::duration_cast<std::chrono::minutes>(local - local_day);
const auto hour = static_cast<unsigned>(time.count() / 60);
const auto minute = static_cast<unsigned>(time.count() % 60);
return {date, hour, minute};
}
TimePoint make_event_time(const Event& event, const Date date, const unsigned hour,
const unsigned minute)
{
if (event.time_basis != TimeBasis::zoned || event.time_zone.empty()) {
return make_local_time(date, hour, minute);
}
const auto* zone = std::chrono::locate_zone(event.time_zone);
const auto value = std::chrono::local_days{date} + std::chrono::hours{hour} +
std::chrono::minutes{minute};
if (zone->get_info(value).result == std::chrono::local_info::nonexistent) {
throw std::runtime_error("local date-time does not exist in source time zone");
}
return zone->to_sys(value, std::chrono::choose::earliest);
}
bool parse_time(const std::string_view text, unsigned& hour, unsigned& minute) noexcept
{
if (text.size() != 5 || text[2] != ':' || text[0] < '0' || text[0] > '9' ||
text[1] < '0' || text[1] > '9' || text[3] < '0' || text[3] > '9' ||
text[4] < '0' || text[4] > '9') {
return false;
}
hour = static_cast<unsigned>((text[0] - '0') * 10 + text[1] - '0');
minute = static_cast<unsigned>((text[3] - '0') * 10 + text[4] - '0');
return hour < 24 && minute < 60;
}
void erase_last_codepoint(std::string& text)
{
if (text.empty()) {
return;
}
auto at = text.size() - 1;
while (at > 0 && (static_cast<unsigned char>(text[at]) & 0xc0U) == 0x80U) {
--at;
}
text.erase(at);
}
bool printable_input(const std::string_view key) noexcept
{
if (key.empty() || key.front() == '\x1b') {
return false;
}
return std::all_of(key.begin(), key.end(), [](const char character) {
const auto byte = static_cast<unsigned char>(character);
return byte >= 0x20U && byte != 0x7fU;
});
}
std::string single_line(std::string_view value)
{
std::string result{value};
std::replace(result.begin(), result.end(), '\n', ' ');
std::replace(result.begin(), result.end(), '\r', ' ');
return result;
}
std::string style(std::string text, const std::string_view code, const bool ansi)
{
if (!ansi || text.empty()) return text;
return "\x1b[" + std::string{code} + 'm' + std::move(text) + "\x1b[0m";
}
std::string interior_line(std::string content, const int width)
{
if (width <= 0) return {};
if (width == 1) return "";
return "" + fit_text(content, width - 2, false) + "";
}
std::string styled_interior_line(std::string content, const int width,
const std::string_view code, const bool ansi)
{
if (width <= 0) return {};
if (width == 1) return "";
return "" + style(fit_text(content, width - 2, false), code, ansi) + "";
}
std::string horizontal_line(const int width, const std::string_view left,
const std::string_view fill, const std::string_view right)
{
if (width <= 0) return {};
if (width == 1) return std::string{left};
std::string result{left};
for (int column = 0; column < width - 2; ++column) result += fill;
result += right;
return result;
}
struct KeyHint {
std::string_view key;
std::string_view label;
};
int hints_width(const KeyHint* const hints, const std::size_t count)
{
int width = 0;
for (std::size_t index = 0; index < count; ++index) {
if (index != 0) width += 3;
width += static_cast<int>(hints[index].key.size() + 1 + hints[index].label.size());
}
return width;
}
// Key bold, label dim, three spaces between hints; the plain layout is
// identical with ANSI disabled. Hints drop from the end to fit.
std::string render_hints(const std::initializer_list<KeyHint> hints, const int width,
const bool ansi)
{
std::size_t count = hints.size();
while (count > 0 && hints_width(hints.begin(), count) > width) --count;
std::string result;
for (std::size_t index = 0; index < count; ++index) {
if (index != 0) result += " ";
result += style(std::string{hints.begin()[index].key}, "1", ansi);
result += ' ';
result += style(std::string{hints.begin()[index].label}, "2", ansi);
}
const int used = hints_width(hints.begin(), count);
if (used < width) result.append(static_cast<std::size_t>(width - used), ' ');
return result;
}
std::string hints_interior_line(const std::initializer_list<KeyHint> hints, const int width,
const bool ansi)
{
if (width <= 0) return {};
if (width == 1) return "";
if (width <= 3) return interior_line({}, width);
return "" + render_hints(hints, width - 4, ansi) + "";
}
// Recurrence frequency display names
constexpr std::array<std::string_view, 5> frequency_names{
"none", "daily", "weekly", "monthly", "yearly",
};
[[nodiscard]] int frequency_name_to_index(const std::string_view name) noexcept
{
for (int i = 0; i < static_cast<int>(frequency_names.size()); ++i) {
if (frequency_names[static_cast<std::size_t>(i)] == name) return i;
}
return 0; // default to none
}
[[nodiscard]] std::string weekday_short_name(const std::chrono::weekday day) noexcept
{
static constexpr std::string_view names[]{"SU", "MO", "TU", "WE", "TH", "FR", "SA"};
return std::string(names[day.c_encoding()]);
}
[[nodiscard]] std::string by_weekday_display(const std::vector<ByWeekday>& weekdays)
{
std::string result;
for (std::size_t i = 0; i < weekdays.size(); ++i) {
if (i != 0) result += ",";
result += weekday_short_name(weekdays[i].day);
}
return result;
}
// Parse a comma-separated list of weekday short names (MO,TU,WE) into ByWeekday entries.
// Returns empty vector on any parse failure.
[[nodiscard]] std::vector<ByWeekday> parse_by_weekday_input(const std::string_view text)
{
std::vector<ByWeekday> result;
if (text.empty()) return result;
std::size_t start = 0;
while (start < text.size()) {
while (start < text.size() && (text[start] == ',' || text[start] == ' ')) ++start;
if (start >= text.size()) break;
std::size_t end = start;
while (end < text.size() && text[end] != ',' && text[end] != ' ') ++end;
const auto token = text.substr(start, end - start);
// Check for ordinal prefix (e.g., 1MO, -1SU)
int ordinal = 0;
std::string_view weekday_part = token;
if (!token.empty() && (token[0] == '+' || token[0] == '-' || (token[0] >= '0' && token[0] <= '9'))) {
std::size_t digit_end = 0;
if (token[0] == '+' || token[0] == '-') digit_end = 1;
while (digit_end < token.size() && token[digit_end] >= '0' && token[digit_end] <= '9') ++digit_end;
if (digit_end > 0U && digit_end < token.size()) {
try {
ordinal = std::stoi(std::string(token.substr(0, digit_end)));
} catch (...) {
return {};
}
if (ordinal < -5 || ordinal > 5) return {};
weekday_part = token.substr(digit_end);
}
}
std::chrono::weekday day{0};
bool found = false;
if (weekday_part == "SU") { day = std::chrono::Sunday; found = true; }
else if (weekday_part == "MO") { day = std::chrono::Monday; found = true; }
else if (weekday_part == "TU") { day = std::chrono::Tuesday; found = true; }
else if (weekday_part == "WE") { day = std::chrono::Wednesday; found = true; }
else if (weekday_part == "TH") { day = std::chrono::Thursday; found = true; }
else if (weekday_part == "FR") { day = std::chrono::Friday; found = true; }
else if (weekday_part == "SA") { day = std::chrono::Saturday; found = true; }
if (!found) return {};
result.push_back({ordinal, day});
start = end;
}
return result;
}
} // namespace
EventEditor::EventEditor(const Date selected_day)
{
if (!selected_day.ok()) {
throw std::invalid_argument("event editor requires a valid selected date");
}
original_.uid = make_uid();
result_ = original_;
values_[index_of(EditorField::start_date)] = format_date(selected_day);
values_[index_of(EditorField::start_time)] = "09:00";
values_[index_of(EditorField::end_date)] = format_date(selected_day);
values_[index_of(EditorField::end_time)] = "10:00";
values_[index_of(EditorField::recurrence_frequency)] = "none";
values_[index_of(EditorField::recurrence_interval)] = "1";
values_[index_of(EditorField::recurrence_count)] = "";
// Default to days of week for weekly: the start day's weekday
const auto start_wd = std::chrono::weekday{std::chrono::sys_days{selected_day}};
values_[index_of(EditorField::recurrence_by_weekday)] = weekday_short_name(start_wd);
}
EventEditor::EventEditor(const Event& event)
: original_{event}, result_{event}, all_day_{event.all_day}, editing_{true}
{
const auto start = event_civil_moment(event, event.start);
const auto end = event_civil_moment(event, event.end);
values_[index_of(EditorField::title)] = event.title;
values_[index_of(EditorField::start_date)] = format_date(start.date);
values_[index_of(EditorField::start_time)] = two_digits(start.hour) + ':' +
two_digits(start.minute);
values_[index_of(EditorField::end_date)] = format_date(end.date);
values_[index_of(EditorField::end_time)] = two_digits(end.hour) + ':' + two_digits(end.minute);
values_[index_of(EditorField::location)] = event.location;
values_[index_of(EditorField::notes)] = event.description;
// Populate recurrence fields from existing rule
if (event.recurrence) {
const auto& rule = *event.recurrence;
std::string freq_name;
switch (rule.frequency) {
case RecurrenceFrequency::daily: freq_name = "daily"; break;
case RecurrenceFrequency::weekly: freq_name = "weekly"; break;
case RecurrenceFrequency::monthly: freq_name = "monthly"; break;
case RecurrenceFrequency::yearly: freq_name = "yearly"; break;
}
values_[index_of(EditorField::recurrence_frequency)] = freq_name;
values_[index_of(EditorField::recurrence_interval)] = std::to_string(rule.interval);
values_[index_of(EditorField::recurrence_count)] = rule.count
? std::to_string(*rule.count) : "";
values_[index_of(EditorField::recurrence_by_weekday)] = by_weekday_display(rule.by_weekday);
} else {
values_[index_of(EditorField::recurrence_frequency)] = "none";
values_[index_of(EditorField::recurrence_interval)] = "1";
values_[index_of(EditorField::recurrence_count)] = "";
const auto start_wd = std::chrono::weekday{std::chrono::sys_days{start.date}};
values_[index_of(EditorField::recurrence_by_weekday)] = weekday_short_name(start_wd);
}
}
std::string_view EventEditor::field_value(const EditorField field) const noexcept
{
if (field == EditorField::all_day) return {};
return values_[index_of(field)];
}
void EventEditor::move_focus(const int direction) noexcept
{
const auto current = static_cast<int>(index_of(focused_));
const auto count = static_cast<int>(field_count);
focused_ = static_cast<EditorField>((current + direction + count) % count);
}
EditorOutcome EventEditor::handle_key(const std::string_view key)
{
if (key == "\x1b") return EditorOutcome::cancel;
if (key == "\x13") return validate() ? EditorOutcome::submit : EditorOutcome::active;
if (key == "\t" || key == "\x1b[B" || key == "\x1b[C") {
move_focus(1);
return EditorOutcome::active;
}
if (key == "\x1b[Z" || key == "\x1b[A" || key == "\x1b[D") {
move_focus(-1);
return EditorOutcome::active;
}
if (key == "\r" || key == "\n") {
if (focused_ == EditorField::notes) {
values_[index_of(focused_)] += '\n';
} else {
move_focus(1);
}
return EditorOutcome::active;
}
if (focused_ == EditorField::all_day) {
if (key == " ") {
all_day_ = !all_day_;
if (all_day_) {
try {
const auto start = parse_date(values_[index_of(EditorField::start_date)]);
const auto end = parse_date(values_[index_of(EditorField::end_date)]);
if (start == end) {
values_[index_of(EditorField::end_date)] =
format_date(from_sys_days(to_sys_days(start) + std::chrono::days{1}));
}
} catch (const std::exception&) {
// Leave malformed input untouched; submit validation will
// focus it and provide the specific correction message.
}
}
}
return EditorOutcome::active;
}
// Recurrence frequency cycling: Space or arrow keys cycle through options
if (focused_ == EditorField::recurrence_frequency) {
if (key == " " || key == "\r" || key == "\n") {
auto& value = values_[index_of(focused_)];
const int current = frequency_name_to_index(value);
const int next = (current + 1) % static_cast<int>(frequency_names.size());
value = frequency_names[static_cast<std::size_t>(next)];
error_.clear();
}
return EditorOutcome::active;
}
auto& value = values_[index_of(focused_)];
if (key == "\x7f" || key == "\b") {
erase_last_codepoint(value);
error_.clear();
} else if (printable_input(key)) {
value.append(key);
error_.clear();
}
return EditorOutcome::active;
}
bool EventEditor::validate()
{
const auto fail = [this](const EditorField field, std::string message) {
focused_ = field;
error_ = std::move(message);
return false;
};
const auto& title = values_[index_of(EditorField::title)];
if (title.find_first_not_of(" \t\r\n") == std::string::npos) {
return fail(EditorField::title, "Title is required.");
}
Date start_day;
Date end_day;
try {
start_day = parse_date(values_[index_of(EditorField::start_date)]);
} catch (const std::exception&) {
return fail(EditorField::start_date, "Start date must be a valid YYYY-MM-DD date.");
}
try {
end_day = parse_date(values_[index_of(EditorField::end_date)]);
} catch (const std::exception&) {
return fail(EditorField::end_date, "End date must be a valid YYYY-MM-DD date.");
}
Event candidate = original_;
if (candidate.uid.empty()) candidate.uid = make_uid();
if (is_recurring(candidate) && all_day_ != candidate.all_day) {
return fail(EditorField::all_day,
"Timed/all-day conversion is unavailable for recurring series.");
}
candidate.title = title;
candidate.location = values_[index_of(EditorField::location)];
candidate.description = values_[index_of(EditorField::notes)];
candidate.all_day = all_day_;
try {
if (all_day_) {
if (to_sys_days(end_day) <= to_sys_days(start_day)) {
return fail(EditorField::end_date,
"All-day end is exclusive and must be at least the next day.");
}
candidate.time_basis = TimeBasis::floating;
candidate.time_zone.clear();
candidate.start = make_event_time(candidate, start_day, 0, 0);
candidate.end = make_event_time(candidate, end_day, 0, 0);
} else {
unsigned start_hour = 0;
unsigned start_minute = 0;
unsigned end_hour = 0;
unsigned end_minute = 0;
if (!parse_time(values_[index_of(EditorField::start_time)], start_hour, start_minute)) {
return fail(EditorField::start_time, "Start time must be a valid HH:MM time.");
}
if (!parse_time(values_[index_of(EditorField::end_time)], end_hour, end_minute)) {
return fail(EditorField::end_time, "End time must be a valid HH:MM time.");
}
candidate.start = make_event_time(candidate, start_day, start_hour, start_minute);
candidate.end = make_event_time(candidate, end_day, end_hour, end_minute);
if (candidate.end <= candidate.start) {
return fail(EditorField::end_time, "End must be after start.");
}
}
} catch (const std::exception&) {
return fail(EditorField::start_date,
"That local date and time cannot be represented in this time zone.");
}
// Build recurrence rule from editor fields
const auto& freq_text = values_[index_of(EditorField::recurrence_frequency)];
if (freq_text != "none") {
RecurrenceRule rule;
// Parse frequency
if (freq_text == "daily") rule.frequency = RecurrenceFrequency::daily;
else if (freq_text == "weekly") rule.frequency = RecurrenceFrequency::weekly;
else if (freq_text == "monthly") rule.frequency = RecurrenceFrequency::monthly;
else if (freq_text == "yearly") rule.frequency = RecurrenceFrequency::yearly;
else {
return fail(EditorField::recurrence_frequency, "Invalid recurrence frequency.");
}
// Parse interval
const auto& interval_text = values_[index_of(EditorField::recurrence_interval)];
if (interval_text.empty()) {
return fail(EditorField::recurrence_interval, "Interval is required.");
}
try {
const auto interval_val = std::stoul(interval_text);
if (interval_val < 1U || interval_val > 32767U) {
return fail(EditorField::recurrence_interval, "Interval must be between 1 and 32767.");
}
rule.interval = static_cast<unsigned>(interval_val);
} catch (const std::exception&) {
return fail(EditorField::recurrence_interval, "Interval must be a positive integer.");
}
// Parse count (optional)
const auto& count_text = values_[index_of(EditorField::recurrence_count)];
if (!count_text.empty()) {
try {
const auto count_val = std::stoul(count_text);
if (count_val < 1U) {
return fail(EditorField::recurrence_count, "Count must be at least 1.");
}
rule.count = static_cast<unsigned>(count_val);
} catch (const std::exception&) {
return fail(EditorField::recurrence_count, "Count must be a positive integer or empty.");
}
}
// Parse BYDAY
const auto& by_weekday_text = values_[index_of(EditorField::recurrence_by_weekday)];
const auto parsed = parse_by_weekday_input(by_weekday_text);
if (parsed.empty() && !by_weekday_text.empty()) {
return fail(EditorField::recurrence_by_weekday, "Use comma-separated day names: MO,TU,WE");
}
rule.by_weekday = parsed;
// If frequency is weekly and by_weekday is empty, fill with DTSTART's weekday
if (rule.frequency == RecurrenceFrequency::weekly && rule.by_weekday.empty()) {
const auto start_wd = std::chrono::weekday{std::chrono::sys_days{start_day}};
rule.by_weekday.push_back({0, start_wd});
}
candidate.recurrence = std::move(rule);
} else {
candidate.recurrence = std::nullopt;
}
result_ = std::move(candidate);
error_.clear();
return true;
}
std::string EventEditor::render(const int requested_width, const int requested_height,
const bool ansi) const
{
const int width = std::max(0, requested_width);
const int height = std::max(0, requested_height);
if (height == 0) return {};
const auto top = horizontal_line(width, "", "", "");
const auto rule = horizontal_line(width, "", "", "");
const auto bottom = horizontal_line(width, "", "", "");
std::vector<std::string> lines;
lines.reserve(16);
lines.push_back(top);
const auto heading = editing_ && is_recurring(original_)
? " NOCAL · EDIT ENTIRE RECURRING SERIES"
: editing_ ? " NOCAL · EDIT APPOINTMENT"
: " NOCAL · NEW APPOINTMENT";
lines.push_back(styled_interior_line(heading, width, "1", ansi));
lines.push_back(hints_interior_line(
{{"Tab", "next field"}, {"Shift-Tab", "previous field"}, {"Space", "toggle"}},
width, ansi));
lines.push_back(styled_interior_line(
original_.time_basis == TimeBasis::zoned && !original_.time_zone.empty()
? " Times shown in source zone: " + original_.time_zone
: " Times shown in your local zone",
width, "2", ansi));
lines.push_back(rule);
const auto& freq_text = values_[index_of(EditorField::recurrence_frequency)];
const bool has_recurrence = freq_text != "none";
for (std::size_t index = 0; index < field_count; ++index) {
const auto field = static_cast<EditorField>(index);
const bool selected = focused_ == field;
// Suppress recurrence fields (except frequency) when frequency is none
if (field == EditorField::recurrence_interval && !has_recurrence) continue;
if (field == EditorField::recurrence_count && !has_recurrence) continue;
if (field == EditorField::recurrence_by_weekday && !has_recurrence) continue;
std::string value;
if (field == EditorField::all_day) {
value = all_day_ ? "[x] spans whole days (end date is exclusive)"
: "[ ] timed appointment";
} else if (all_day_ && (field == EditorField::start_time || field == EditorField::end_time)) {
value = "— not used for all-day appointments —";
} else if (field == EditorField::recurrence_frequency) {
value = std::string{freq_text};
if (selected) value += " (Space to cycle)";
} else if (field == EditorField::recurrence_interval) {
value = values_[index];
if (selected) value += "_";
} else if (field == EditorField::recurrence_count) {
const auto& count_val = values_[index];
value = count_val.empty() ? "(unlimited)" : count_val;
if (selected) value += "_";
} else if (field == EditorField::recurrence_by_weekday) {
value = values_[index];
if (selected) value += "_";
} else {
value = single_line(values_[index]);
if (selected) value += "_";
}
std::string row = selected ? " " : " ";
row += fit_text(labels[index], 11, false);
row += " ";
row += value;
lines.push_back(styled_interior_line(std::move(row), width, selected ? "7" : "", ansi));
}
lines.push_back(rule);
lines.push_back(error_.empty()
? styled_interior_line(" Ready", width, "2", ansi)
: styled_interior_line(" ! " + error_, width, "1;31", ansi));
lines.push_back(hints_interior_line({{"Ctrl-S", "save"}, {"Esc", "cancel"}}, width, ansi));
lines.push_back(bottom);
// Keep the active field visible on short terminals by selecting a window
// from the complete form, while retaining a top and bottom frame.
if (static_cast<int>(lines.size()) > height) {
std::vector<std::string> compact;
compact.reserve(static_cast<std::size_t>(height));
compact.push_back(top);
if (height > 2) {
const int available = height - 2;
const int selected_line = 5 + static_cast<int>(index_of(focused_));
const int maximum_start = static_cast<int>(lines.size()) - 1 - available;
const int start = std::clamp(selected_line - available / 2, 1,
std::max(1, maximum_start));
for (int offset = 0; offset < available; ++offset) {
compact.push_back(lines[static_cast<std::size_t>(start + offset)]);
}
}
if (height > 1) compact.push_back(bottom);
lines = std::move(compact);
}
while (static_cast<int>(lines.size()) < height) {
lines.insert(lines.end() - 1, interior_line({}, width));
}
std::string frame;
for (int row = 0; row < height; ++row) {
if (row != 0) frame += '\n';
frame += lines[static_cast<std::size_t>(row)];
}
return frame;
}
} // namespace nocal::tui