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.
681 lines
27 KiB
C++
681 lines
27 KiB
C++
#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
|