#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", }; 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) + "│"; } } // 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"; } 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; } std::string_view EventEditor::field_value(const EditorField field) const noexcept { if (field == EditorField::all_day || field == EditorField::count) 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; } 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."); } // DATE values have no timezone in iCalendar. Normalizing here keeps // the in-memory result identical to a save/reload round trip. 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."); } 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); for (std::size_t index = 0; index < field_count; ++index) { const auto field = static_cast(index); const bool selected = focused_ == field; 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 { 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