feat: initialize Nocal terminal calendar

This commit is contained in:
2026-07-17 21:24:06 +01:00
commit 22c6399056
37 changed files with 6146 additions and 0 deletions

459
src/tui/EventEditor.cpp Normal file
View File

@@ -0,0 +1,459 @@
#include "nocal/tui/EventEditor.hpp"
#include <algorithm>
#include <array>
#include <atomic>
#include <chrono>
#include <cstdint>
#include <cstdio>
#include <ctime>
#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)};
}
std::string format_time(const TimePoint point)
{
const auto [hour, minute] = local_hour_minute(point);
return two_digits(hour) + ':' + two_digits(minute);
}
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;
}
struct DecodedCodepoint {
std::uint32_t value;
std::size_t length;
};
DecodedCodepoint decode_codepoint(const std::string_view text, const std::size_t at) noexcept
{
const auto first = static_cast<unsigned char>(text[at]);
if (first < 0x80U) return {first, 1};
std::uint32_t value = 0;
std::size_t length = 1;
if ((first & 0xe0U) == 0xc0U) {
value = first & 0x1fU;
length = 2;
} else if ((first & 0xf0U) == 0xe0U) {
value = first & 0x0fU;
length = 3;
} else if ((first & 0xf8U) == 0xf0U) {
value = first & 0x07U;
length = 4;
} else {
return {0xfffdU, 1};
}
if (at + length > text.size()) return {0xfffdU, 1};
for (std::size_t offset = 1; offset < length; ++offset) {
const auto byte = static_cast<unsigned char>(text[at + offset]);
if ((byte & 0xc0U) != 0x80U) return {0xfffdU, 1};
value = (value << 6U) | (byte & 0x3fU);
}
return {value, length};
}
int codepoint_width(const std::uint32_t value) noexcept
{
if (value == 0 || (value >= 0x0300U && value <= 0x036fU) ||
(value >= 0x1ab0U && value <= 0x1affU) ||
(value >= 0x1dc0U && value <= 0x1dffU) ||
(value >= 0x20d0U && value <= 0x20ffU) ||
(value >= 0xfe00U && value <= 0xfe0fU) ||
(value >= 0xfe20U && value <= 0xfe2fU)) {
return 0;
}
if ((value >= 0x1100U && value <= 0x115fU) || value == 0x2329U || value == 0x232aU ||
(value >= 0x2e80U && value <= 0xa4cfU) ||
(value >= 0xac00U && value <= 0xd7a3U) ||
(value >= 0xf900U && value <= 0xfaffU) ||
(value >= 0xfe10U && value <= 0xfe19U) ||
(value >= 0xfe30U && value <= 0xfe6fU) ||
(value >= 0xff00U && value <= 0xff60U) ||
(value >= 0xffe0U && value <= 0xffe6U) ||
(value >= 0x2600U && value <= 0x27bfU) ||
(value >= 0x1f300U && value <= 0x1faffU) ||
(value >= 0x20000U && value <= 0x3fffdU)) {
return 2;
}
return 1;
}
std::string fit(std::string_view text, const int width)
{
if (width <= 0) return {};
std::string result;
int used = 0;
for (std::size_t at = 0; at < text.size();) {
const auto decoded = decode_codepoint(text, at);
const auto cell_width = codepoint_width(decoded.value);
if (used + cell_width > width) break;
result.append(text.substr(at, decoded.length));
at += decoded.length;
used += cell_width;
}
result.append(static_cast<std::size_t>(width - used), ' ');
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(content, width - 2) + "";
}
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(content, width - 2), 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;
}
} // 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}
{
values_[index_of(EditorField::title)] = event.title;
values_[index_of(EditorField::start_date)] = format_date(local_date(event.start));
values_[index_of(EditorField::start_time)] = format_time(event.start);
values_[index_of(EditorField::end_date)] = format_date(local_date(event.end));
values_[index_of(EditorField::end_time)] = format_time(event.end);
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();
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.start = local_day_start(start_day);
candidate.end = local_day_start(end_day);
} 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_local_time(start_day, start_hour, start_minute);
candidate.end = make_local_time(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);
lines.push_back(styled_interior_line(editing_ ? " NOCAL · EDIT APPOINTMENT"
: " NOCAL · NEW APPOINTMENT",
width, "1", ansi));
lines.push_back(styled_interior_line(" Tab/↓ next Shift-Tab/↑ back Space toggle",
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(labels[index], 11);
row += " ";
row += value;
lines.push_back(styled_interior_line(std::move(row), width, selected ? "7" : "", ansi));
}
lines.push_back(rule);
lines.push_back(interior_line(error_.empty() ? " Ready" : " ! " + error_, width));
lines.push_back(styled_interior_line(" Ctrl-S save Esc cancel", width, "1", 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 = 4 + 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