Files
nocal/src/tui/EventEditor.cpp
Bernardo Magri a159933baa refactor: shared Unicode display-width utility with grapheme cluster support
Consolidate two inconsistent codepoint_width implementations from
Screen.cpp and EventEditor.cpp into nocal/tui/unicode.hpp. The unified
table covers all CJK, Hangul, emoji, symbol, and combining mark ranges.
display_width and fit_text handle grapheme clusters (ZWJ sequences,
skin-tone/hair modifiers, regional indicator pairs, variation selectors)
so calendar text with emoji or East Asian characters renders correctly.
2026-07-23 18:03:33 +01:00

482 lines
18 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",
};
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) + "";
}
} // 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<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;
}
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<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);
for (std::size_t index = 0; index < field_count; ++index) {
const auto field = static_cast<EditorField>(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<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