feat: initialize Nocal terminal calendar
This commit is contained in:
459
src/tui/EventEditor.cpp
Normal file
459
src/tui/EventEditor.cpp
Normal 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
|
||||
Reference in New Issue
Block a user