#include "nocal/tui/EventEditor.hpp" #include "nocal/tui/unicode.hpp" #include #include #include #include #include #include #include #include #include #include #include #include #include namespace nocal::tui { namespace { constexpr std::size_t field_count = static_cast(EditorField::count); constexpr std::array 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(field); } std::string make_uid() { static std::atomic sequence{0}; static const std::uint64_t seed = [] { std::random_device random; const auto high = static_cast(random()) << 32U; return high ^ static_cast(random()); }(); const auto now = std::chrono::duration_cast( std::chrono::system_clock::now().time_since_epoch()) .count(); std::ostringstream stream; stream << "nocal-" << std::hex << static_cast(now) << '-' << (seed ^ sequence.fetch_add(1, std::memory_order_relaxed)) << "@local"; return stream.str(); } std::string two_digits(const unsigned value) { std::array buffer{}; std::snprintf(buffer.data(), buffer.size(), "%02u", value); return {buffer.data(), 2}; } std::pair 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(value.tm_hour), static_cast(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(local); const std::chrono::year_month_day date{local_day}; const auto time = std::chrono::duration_cast(local - local_day); const auto hour = static_cast(time.count() / 60); const auto minute = static_cast(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((text[0] - '0') * 10 + text[1] - '0'); minute = static_cast((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(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(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(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 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(width - used), ' '); return result; } std::string hints_interior_line(const std::initializer_list 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 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(frequency_names.size()); ++i) { if (frequency_names[static_cast(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& 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 parse_by_weekday_input(const std::string_view text) { std::vector 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(index_of(focused_)); const auto count = static_cast(field_count); focused_ = static_cast((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(frequency_names.size()); value = frequency_names[static_cast(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(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(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 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(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(lines.size()) > height) { std::vector compact; compact.reserve(static_cast(height)); compact.push_back(top); if (height > 2) { const int available = height - 2; const int selected_line = 5 + static_cast(index_of(focused_)); const int maximum_start = static_cast(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(start + offset)]); } } if (height > 1) compact.push_back(bottom); lines = std::move(compact); } while (static_cast(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(row)]; } return frame; } } // namespace nocal::tui