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.
482 lines
18 KiB
C++
482 lines
18 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",
|
||
};
|
||
|
||
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
|