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

172
src/domain/date.cpp Normal file
View File

@@ -0,0 +1,172 @@
#include "nocal/domain/date.hpp"
#include <array>
#include <cstdio>
#include <ctime>
#include <stdexcept>
namespace nocal {
namespace {
[[nodiscard]] unsigned parse_component(std::string_view text)
{
unsigned value = 0;
for (const char ch : text) {
if (ch < '0' || ch > '9') {
throw std::invalid_argument("date contains a non-digit");
}
value = value * 10U + static_cast<unsigned>(ch - '0');
}
return value;
}
[[nodiscard]] std::tm local_tm(std::time_t time)
{
std::tm result{};
#if defined(_WIN32)
if (::localtime_s(&result, &time) != 0) {
throw std::runtime_error("could not convert timestamp to local time");
}
#else
if (::localtime_r(&time, &result) == nullptr) {
throw std::runtime_error("could not convert timestamp to local time");
}
#endif
return result;
}
} // namespace
std::chrono::sys_days to_sys_days(Date date)
{
if (!date.ok()) {
throw std::invalid_argument("invalid Gregorian date");
}
return std::chrono::sys_days{date};
}
Date from_sys_days(std::chrono::sys_days days) noexcept
{
return Date{days};
}
Date parse_date(std::string_view text)
{
if (text.size() != 10 || text[4] != '-' || text[7] != '-') {
throw std::invalid_argument("date must have YYYY-MM-DD form");
}
const auto year = static_cast<int>(parse_component(text.substr(0, 4)));
const auto month = parse_component(text.substr(5, 2));
const auto day = parse_component(text.substr(8, 2));
const Date result{std::chrono::year{year}, std::chrono::month{month},
std::chrono::day{day}};
if (!result.ok()) {
throw std::invalid_argument("invalid Gregorian date");
}
return result;
}
std::string format_date(Date date)
{
if (!date.ok()) {
throw std::invalid_argument("invalid Gregorian date");
}
std::array<char, 11> buffer{};
const int count = std::snprintf(buffer.data(), buffer.size(), "%04d-%02u-%02u",
static_cast<int>(date.year()),
static_cast<unsigned>(date.month()),
static_cast<unsigned>(date.day()));
if (count != 10) {
throw std::out_of_range("date cannot be represented as YYYY-MM-DD");
}
return {buffer.data(), 10};
}
Date local_date(TimePoint point)
{
const auto time = Clock::to_time_t(point);
const auto tm = local_tm(time);
return Date{std::chrono::year{tm.tm_year + 1900},
std::chrono::month{static_cast<unsigned>(tm.tm_mon + 1)},
std::chrono::day{static_cast<unsigned>(tm.tm_mday)}};
}
Date today()
{
return local_date(Clock::now());
}
TimePoint make_local_time(Date date, unsigned hour, unsigned minute, unsigned second)
{
if (!date.ok()) {
throw std::invalid_argument("invalid Gregorian date");
}
if (hour > 23 || minute > 59 || second > 59) {
throw std::invalid_argument("invalid local time of day");
}
std::tm tm{};
tm.tm_year = static_cast<int>(date.year()) - 1900;
tm.tm_mon = static_cast<int>(static_cast<unsigned>(date.month())) - 1;
tm.tm_mday = static_cast<int>(static_cast<unsigned>(date.day()));
tm.tm_hour = static_cast<int>(hour);
tm.tm_min = static_cast<int>(minute);
tm.tm_sec = static_cast<int>(second);
tm.tm_isdst = -1;
const auto time = std::mktime(&tm);
const auto checked = local_tm(time);
if (checked.tm_year != tm.tm_year || checked.tm_mon != tm.tm_mon ||
checked.tm_mday != tm.tm_mday || checked.tm_hour != tm.tm_hour ||
checked.tm_min != tm.tm_min || checked.tm_sec != tm.tm_sec) {
throw std::runtime_error("local date-time is not representable");
}
return Clock::from_time_t(time);
}
TimePoint local_day_start(Date date)
{
return make_local_time(date);
}
TimePoint local_day_end(Date date)
{
const auto next = from_sys_days(to_sys_days(date) + std::chrono::days{1});
return local_day_start(next);
}
bool same_local_day(TimePoint lhs, TimePoint rhs)
{
return local_date(lhs) == local_date(rhs);
}
bool is_today(Date date)
{
return date.ok() && date == today();
}
Date first_day_of_month(YearMonth month)
{
if (!month.ok()) {
throw std::invalid_argument("invalid Gregorian month");
}
return Date{month / std::chrono::day{1}};
}
YearMonth following_month(YearMonth month)
{
if (!month.ok()) {
throw std::invalid_argument("invalid Gregorian month");
}
return month + std::chrono::months{1};
}
unsigned monday_first_weekday(Date date)
{
const auto weekday = std::chrono::weekday{to_sys_days(date)}.c_encoding();
return (weekday + 6U) % 7U;
}
} // namespace nocal

View File

@@ -0,0 +1,78 @@
#include "nocal/domain/event_query.hpp"
#include <algorithm>
#include <stdexcept>
namespace nocal {
bool event_less(const Event& lhs, const Event& rhs) noexcept
{
if (lhs.all_day != rhs.all_day) {
return lhs.all_day;
}
if (lhs.start != rhs.start) {
return lhs.start < rhs.start;
}
if (lhs.end != rhs.end) {
return lhs.end < rhs.end;
}
if (lhs.title != rhs.title) {
return lhs.title < rhs.title;
}
return lhs.uid < rhs.uid;
}
void sort_events(EventRefs& events)
{
std::stable_sort(events.begin(), events.end(), [](EventRef lhs, EventRef rhs) {
return event_less(lhs.get(), rhs.get());
});
}
bool event_overlaps(const Event& event, TimePoint begin, TimePoint end) noexcept
{
if (begin >= end || !has_valid_interval(event)) {
return false;
}
if (event.start == event.end) {
return event.start >= begin && event.start < end;
}
return event.start < end && event.end > begin;
}
bool event_occurs_on(const Event& event, Date date)
{
return event_overlaps(event, local_day_start(date), local_day_end(date));
}
EventRefs events_overlapping(std::span<const Event> events, TimePoint begin,
TimePoint end)
{
if (begin > end) {
throw std::invalid_argument("event query begins after it ends");
}
EventRefs result;
result.reserve(events.size());
for (const auto& event : events) {
if (event_overlaps(event, begin, end)) {
result.emplace_back(event);
}
}
sort_events(result);
return result;
}
EventRefs events_on_day(std::span<const Event> events, Date date)
{
return events_overlapping(events, local_day_start(date), local_day_end(date));
}
EventRefs events_in_month(std::span<const Event> events, YearMonth month)
{
const auto begin = local_day_start(first_day_of_month(month));
const auto end = local_day_start(first_day_of_month(following_month(month)));
return events_overlapping(events, begin, end);
}
} // namespace nocal

View File

@@ -0,0 +1,56 @@
#include "nocal/domain/month_layout.hpp"
#include <stdexcept>
namespace nocal {
const MonthCell& MonthLayout::at(std::size_t row, std::size_t column) const
{
if (row >= rows || column >= columns) {
throw std::out_of_range("month cell is outside the 6x7 grid");
}
return cells[row * columns + column];
}
const MonthCell* MonthLayout::find(Date date) const noexcept
{
for (const auto& cell : cells) {
if (cell.date == date) {
return &cell;
}
}
return nullptr;
}
Date MonthLayout::first_visible_date() const noexcept
{
return cells.front().date;
}
Date MonthLayout::last_visible_date() const noexcept
{
return cells.back().date;
}
MonthLayout make_month_layout(YearMonth month)
{
const auto first = first_day_of_month(month);
const auto grid_start = to_sys_days(first) -
std::chrono::days{monday_first_weekday(first)};
MonthLayout result;
result.month = month;
for (std::size_t index = 0; index < result.cells.size(); ++index) {
const auto date = from_sys_days(grid_start +
std::chrono::days{static_cast<long long>(index)});
result.cells[index] = MonthCell{
.date = date,
.in_month = date.year() == month.year() && date.month() == month.month(),
.row = index / MonthLayout::columns,
.column = index % MonthLayout::columns,
};
}
return result;
}
} // namespace nocal

213
src/main.cpp Normal file
View File

@@ -0,0 +1,213 @@
#include "nocal/domain/date.hpp"
#include "nocal/storage/ics_store.hpp"
#include "nocal/tui/Screen.hpp"
#include "nocal/tui/TuiApp.hpp"
#include <chrono>
#include <cstdlib>
#include <filesystem>
#include <iostream>
#include <optional>
#include <span>
#include <stdexcept>
#include <string>
#include <string_view>
#include <utility>
#include <vector>
#include <unistd.h>
namespace {
constexpr std::string_view version = "0.1.0";
struct Options {
std::filesystem::path calendar_path;
bool demo{false};
bool print{false};
bool restore_backup{false};
bool no_color{false};
};
std::filesystem::path default_calendar_path()
{
if (const char* data_home = std::getenv("XDG_DATA_HOME");
data_home != nullptr && *data_home != '\0') {
return std::filesystem::path{data_home} / "nocal" / "calendar.ics";
}
if (const char* home = std::getenv("HOME"); home != nullptr && *home != '\0') {
return std::filesystem::path{home} / ".local" / "share" / "nocal" / "calendar.ics";
}
return "calendar.ics";
}
void print_help(std::ostream& output)
{
output <<
"Usage: nocal [OPTIONS] [CALENDAR.ics]\n\n"
"A theme-native terminal month calendar.\n\n"
" -c, --calendar PATH read events from PATH\n"
" --demo add sample appointments without saving them\n"
" --print print one plain-text frame and exit\n"
" --restore-backup restore PATH.bak over PATH and exit\n"
" --no-color disable ANSI styling\n"
" -h, --help show this help\n"
" -V, --version show the version\n\n"
"Keys: arrows/hjkl move days, PageUp/PageDown or p/n change month,\n"
" Tab/Shift-Tab focus appointments, Enter reads, Esc returns,\n"
" a adds, e edits, d deletes; Ctrl-S saves inside the editor,\n"
" u undoes the last change, Ctrl-R redoes it,\n"
" t jumps to today, ? toggles help, q quits.\n";
}
Options parse_options(int argc, char** argv)
{
Options options{.calendar_path = default_calendar_path()};
bool positional_seen = false;
for (int index = 1; index < argc; ++index) {
const std::string_view argument{argv[index]};
if (argument == "-h" || argument == "--help") {
print_help(std::cout);
std::exit(EXIT_SUCCESS);
}
if (argument == "-V" || argument == "--version") {
std::cout << "nocal " << version << '\n';
std::exit(EXIT_SUCCESS);
}
if (argument == "--demo") {
options.demo = true;
} else if (argument == "--print") {
options.print = true;
} else if (argument == "--restore-backup") {
options.restore_backup = true;
} else if (argument == "--no-color") {
options.no_color = true;
} else if (argument == "-c" || argument == "--calendar") {
if (++index >= argc) {
throw std::invalid_argument(std::string{argument} + " requires a path");
}
options.calendar_path = argv[index];
positional_seen = true;
} else if (!argument.empty() && argument.front() == '-') {
throw std::invalid_argument("unknown option: " + std::string{argument});
} else if (positional_seen) {
throw std::invalid_argument("only one calendar path may be supplied");
} else {
options.calendar_path = argument;
positional_seen = true;
}
}
if (options.restore_backup && options.demo) {
throw std::invalid_argument("--restore-backup cannot be combined with --demo");
}
if (options.restore_backup && options.print) {
throw std::invalid_argument("--restore-backup cannot be combined with --print");
}
return options;
}
nocal::Date shifted(nocal::Date date, int offset)
{
return nocal::from_sys_days(nocal::to_sys_days(date) + std::chrono::days{offset});
}
std::vector<nocal::Event> demo_events()
{
const auto day = nocal::today();
nocal::Event planning;
planning.uid = "demo-planning@nocal";
planning.title = "Nomarchy planning";
planning.start = nocal::make_local_time(day, 9, 30);
planning.end = nocal::make_local_time(day, 10, 15);
planning.location = "Terminal 1";
planning.calendar_id = "work";
nocal::Event release;
release.uid = "demo-release@nocal";
release.title = "Release window";
release.start = nocal::local_day_start(shifted(day, 2));
release.end = nocal::local_day_start(shifted(day, 3));
release.all_day = true;
release.calendar_id = "work";
nocal::Event coffee;
coffee.uid = "demo-coffee@nocal";
coffee.title = "Coffee with Luna";
coffee.start = nocal::make_local_time(shifted(day, -3), 14, 0);
coffee.end = nocal::make_local_time(shifted(day, -3), 14, 45);
coffee.location = "Nomarchy café";
coffee.calendar_id = "personal";
return {std::move(planning), std::move(release), std::move(coffee)};
}
int print_frame(std::span<const nocal::Event> events, bool no_color)
{
const auto day = nocal::today();
const nocal::tui::ScreenState state{
.visible_month = day.year() / day.month(), .selected_day = day, .today = day,
.width = 120, .height = 36, .show_help = false,
.ansi = !no_color && ::isatty(STDOUT_FILENO) != 0,
.focused_event_id = std::nullopt,
.show_event_details = false,
.confirm_delete = false,
.notification = {},
.notification_is_error = false,
};
std::cout << nocal::tui::render_month(events, state) << '\n';
return std::cout ? EXIT_SUCCESS : EXIT_FAILURE;
}
} // namespace
int main(int argc, char** argv)
{
try {
const auto options = parse_options(argc, argv);
auto loaded = nocal::storage::IcsStore::load(options.calendar_path);
for (const auto& warning : loaded.warnings) {
std::cerr << "nocal: " << warning << '\n';
}
if (options.restore_backup) {
const auto backup = nocal::storage::IcsStore::backup_path(options.calendar_path);
nocal::storage::IcsStore::restore_backup(
options.calendar_path, loaded.revision);
std::cout << "Restored backup " << backup.string() << " to "
<< options.calendar_path.string() << '\n';
return std::cout ? EXIT_SUCCESS : EXIT_FAILURE;
}
if (options.demo) {
auto samples = demo_events();
loaded.events.insert(loaded.events.end(), samples.begin(), samples.end());
}
const bool non_interactive = ::isatty(STDIN_FILENO) == 0 ||
::isatty(STDOUT_FILENO) == 0;
if (options.print || non_interactive) {
return print_frame(loaded.events, true);
}
if (options.no_color) {
::setenv("NO_COLOR", "1", 1);
}
nocal::tui::TuiApp::SaveCallback saver;
if (!options.demo) {
if (loaded.safe_to_rewrite) {
const auto calendar_path = options.calendar_path;
saver = [calendar_path, revision = loaded.revision](
const std::span<const nocal::Event> events) mutable {
revision = nocal::storage::IcsStore::save(
calendar_path, events, revision);
};
} else {
saver = [](std::span<const nocal::Event>) {
throw std::runtime_error(
"calendar is read-only because it contains unsupported iCalendar data");
};
}
}
nocal::tui::TuiApp app{loaded.events, std::move(saver)};
return app.run() == nocal::tui::ExitReason::io_error ? EXIT_FAILURE : EXIT_SUCCESS;
} catch (const std::exception& error) {
std::cerr << "nocal: " << error.what() << '\n';
return EXIT_FAILURE;
}
}

976
src/storage/ics_store.cpp Normal file
View File

@@ -0,0 +1,976 @@
#include "nocal/storage/ics_store.hpp"
#include <algorithm>
#include <charconv>
#include <chrono>
#include <cctype>
#include <ctime>
#include <fstream>
#include <iterator>
#include <optional>
#include <sstream>
#include <stdexcept>
#include <string_view>
#include <system_error>
#if !defined(_WIN32)
#include <cerrno>
#include <cstring>
#include <fcntl.h>
#include <sys/file.h>
#include <sys/stat.h>
#include <unistd.h>
#endif
namespace nocal::storage {
namespace {
using namespace std::chrono;
struct ParsedTime {
TimePoint value;
bool is_date = false;
std::optional<year_month_day> date;
};
struct PendingEvent {
std::optional<std::string> uid;
std::string title;
std::string location;
std::string description;
std::string calendar_id;
std::string color;
std::optional<ParsedTime> start;
std::optional<ParsedTime> end;
};
[[nodiscard]] std::string upper(std::string_view value) {
std::string result(value);
std::transform(result.begin(), result.end(), result.begin(), [](unsigned char ch) {
return static_cast<char>(std::toupper(ch));
});
return result;
}
[[nodiscard]] std::string unescape_text(std::string_view value) {
std::string result;
result.reserve(value.size());
for (std::size_t i = 0; i < value.size(); ++i) {
if (value[i] != '\\' || i + 1 == value.size()) {
result.push_back(value[i]);
continue;
}
const char escaped = value[++i];
if (escaped == 'n' || escaped == 'N') {
result.push_back('\n');
} else {
// RFC 5545 TEXT escapes '\\', ';', and ','. Keeping the escaped
// character is the least surprising recovery for unknown escapes.
result.push_back(escaped);
}
}
return result;
}
[[nodiscard]] std::string escape_text(std::string_view value) {
std::string result;
result.reserve(value.size());
for (std::size_t i = 0; i < value.size(); ++i) {
switch (value[i]) {
case '\\': result += "\\\\"; break;
case ';': result += "\\;"; break;
case ',': result += "\\,"; break;
case '\r':
if (i + 1 < value.size() && value[i + 1] == '\n') {
++i;
}
result += "\\n";
break;
case '\n': result += "\\n"; break;
default: result.push_back(value[i]); break;
}
}
return result;
}
[[nodiscard]] bool parse_fixed_int(
std::string_view value, std::size_t offset, std::size_t count, int& destination) {
if (offset + count > value.size()) {
return false;
}
const char* first = value.data() + offset;
const char* last = first + count;
if (!std::all_of(first, last, [](unsigned char ch) { return std::isdigit(ch); })) {
return false;
}
const auto [ptr, ec] = std::from_chars(first, last, destination);
return ec == std::errc{} && ptr == last;
}
[[nodiscard]] std::optional<year_month_day> parse_ymd(std::string_view value) {
if (value.size() != 8) {
return std::nullopt;
}
int year_value = 0;
int month_value = 0;
int day_value = 0;
if (!parse_fixed_int(value, 0, 4, year_value)
|| !parse_fixed_int(value, 4, 2, month_value)
|| !parse_fixed_int(value, 6, 2, day_value)) {
return std::nullopt;
}
const year_month_day date{
year{year_value}, month{static_cast<unsigned>(month_value)}, day{static_cast<unsigned>(day_value)}};
return date.ok() ? std::optional{date} : std::nullopt;
}
[[nodiscard]] std::optional<TimePoint> make_local(
year_month_day date, int hour_value, int minute_value, int second_value) {
std::tm local{};
local.tm_year = static_cast<int>(date.year()) - 1900;
local.tm_mon = static_cast<int>(static_cast<unsigned>(date.month())) - 1;
local.tm_mday = static_cast<int>(static_cast<unsigned>(date.day()));
local.tm_hour = hour_value;
local.tm_min = minute_value;
local.tm_sec = second_value;
local.tm_isdst = -1;
const std::time_t converted = std::mktime(&local);
// mktime may normalize invalid or nonexistent local times. Accept timezone
// normalization only when the requested civil fields survive unchanged.
if (local.tm_year != static_cast<int>(date.year()) - 1900
|| local.tm_mon != static_cast<int>(static_cast<unsigned>(date.month())) - 1
|| local.tm_mday != static_cast<int>(static_cast<unsigned>(date.day()))
|| local.tm_hour != hour_value || local.tm_min != minute_value
|| local.tm_sec != second_value) {
return std::nullopt;
}
return system_clock::from_time_t(converted);
}
[[nodiscard]] std::optional<ParsedTime> parse_time(
std::string_view parameters, std::string_view value, std::string& warning) {
const std::string normalized_parameters = upper(parameters);
bool value_is_date = false;
bool has_tzid = false;
std::size_t parameter_start = 0;
while (parameter_start <= normalized_parameters.size()) {
const std::size_t parameter_end = normalized_parameters.find(';', parameter_start);
const std::string_view parameter = std::string_view(normalized_parameters).substr(
parameter_start, parameter_end == std::string::npos
? std::string::npos : parameter_end - parameter_start);
value_is_date = value_is_date || parameter == "VALUE=DATE";
has_tzid = has_tzid || parameter.starts_with("TZID=");
if (parameter_end == std::string::npos) {
break;
}
parameter_start = parameter_end + 1;
}
if (value_is_date || (parameters.empty() && value.size() == 8)) {
const auto date = parse_ymd(value);
if (!date) {
warning = "invalid DATE value '" + std::string(value) + "'";
return std::nullopt;
}
const auto local = make_local(*date, 0, 0, 0);
if (!local) {
warning = "DATE does not map to a local midnight: '" + std::string(value) + "'";
return std::nullopt;
}
return ParsedTime{*local, true, *date};
}
if (has_tzid) {
warning = "TZID is not supported; DATE-TIME was interpreted in the process local timezone";
}
const bool utc = !value.empty() && value.back() == 'Z';
const std::size_t expected_size = utc ? 16 : 15;
if (value.size() != expected_size || value[8] != 'T') {
warning = "invalid DATE-TIME value '" + std::string(value) + "'";
return std::nullopt;
}
const auto date = parse_ymd(value.substr(0, 8));
int hour_value = 0;
int minute_value = 0;
int second_value = 0;
if (!date || !parse_fixed_int(value, 9, 2, hour_value)
|| !parse_fixed_int(value, 11, 2, minute_value)
|| !parse_fixed_int(value, 13, 2, second_value)
|| hour_value > 23 || minute_value > 59 || second_value > 60) {
warning = "invalid DATE-TIME value '" + std::string(value) + "'";
return std::nullopt;
}
// POSIX time has no representation for a leap second. Normalize it to the
// first instant of the next minute.
const int represented_second = std::min(second_value, 59);
TimePoint result;
if (utc) {
result = sys_days{*date} + hours{hour_value} + minutes{minute_value}
+ seconds{represented_second};
} else {
const auto local = make_local(*date, hour_value, minute_value, represented_second);
if (!local) {
warning = "DATE-TIME does not exist in the local timezone: '" + std::string(value) + "'";
return std::nullopt;
}
result = *local;
}
if (second_value == 60) {
result += seconds{1};
}
return ParsedTime{result, false, std::nullopt};
}
[[nodiscard]] std::vector<std::string> unfold(std::istream& input) {
std::vector<std::string> lines;
std::string line;
while (std::getline(input, line)) {
if (!line.empty() && line.back() == '\r') {
line.pop_back();
}
if (!line.empty() && (line.front() == ' ' || line.front() == '\t')) {
if (!lines.empty()) {
lines.back().append(line.begin() + 1, line.end());
}
} else {
lines.push_back(std::move(line));
}
}
return lines;
}
[[nodiscard]] std::size_t content_separator(std::string_view line) {
bool quoted = false;
for (std::size_t i = 0; i < line.size(); ++i) {
if (line[i] == '"') {
quoted = !quoted;
} else if (line[i] == ':' && !quoted) {
return i;
}
}
return std::string_view::npos;
}
void add_warning(LoadResult& result, std::size_t line, std::string message) {
result.warnings.push_back(
"line " + std::to_string(line) + ": " + std::move(message));
}
void mark_unsafe_to_rewrite(LoadResult& result) {
if (result.safe_to_rewrite) {
result.safe_to_rewrite = false;
result.warnings.emplace_back(
"calendar contains data nocal cannot preserve; editing is disabled to prevent data loss");
}
}
[[nodiscard]] bool supported_time_parameters(std::string_view parameters) {
const std::string normalized = upper(parameters);
return normalized.empty() || normalized == "VALUE=DATE";
}
[[nodiscard]] std::tm utc_tm(std::time_t time) {
std::tm result{};
#if defined(_WIN32)
gmtime_s(&result, &time);
#else
gmtime_r(&time, &result);
#endif
return result;
}
[[nodiscard]] std::tm local_tm(std::time_t time) {
std::tm result{};
#if defined(_WIN32)
localtime_s(&result, &time);
#else
localtime_r(&time, &result);
#endif
return result;
}
[[nodiscard]] std::string two_digits(int value) {
std::string result(2, '0');
result[0] = static_cast<char>('0' + (value / 10) % 10);
result[1] = static_cast<char>('0' + value % 10);
return result;
}
[[nodiscard]] std::string four_digits(int value) {
std::string result(4, '0');
for (int index = 3; index >= 0; --index) {
result[static_cast<std::size_t>(index)] = static_cast<char>('0' + value % 10);
value /= 10;
}
return result;
}
[[nodiscard]] std::string format_date(TimePoint value) {
const std::tm local = local_tm(system_clock::to_time_t(value));
return four_digits(local.tm_year + 1900) + two_digits(local.tm_mon + 1)
+ two_digits(local.tm_mday);
}
[[nodiscard]] std::string format_utc_time(TimePoint value) {
const auto whole_seconds = floor<seconds>(value);
const std::tm utc = utc_tm(system_clock::to_time_t(whole_seconds));
return four_digits(utc.tm_year + 1900) + two_digits(utc.tm_mon + 1)
+ two_digits(utc.tm_mday) + "T" + two_digits(utc.tm_hour)
+ two_digits(utc.tm_min) + two_digits(utc.tm_sec) + "Z";
}
[[nodiscard]] std::size_t utf8_boundary(std::string_view value, std::size_t begin, std::size_t limit) {
std::size_t end = std::min(value.size(), begin + limit);
while (end > begin && end < value.size()
&& (static_cast<unsigned char>(value[end]) & 0xC0U) == 0x80U) {
--end;
}
return end == begin ? std::min(value.size(), begin + limit) : end;
}
void write_folded(std::ostream& output, std::string_view content_line) {
std::size_t offset = 0;
bool first = true;
do {
const std::size_t available = first ? 75 : 74;
const std::size_t end = utf8_boundary(content_line, offset, available);
if (!first) {
output.put(' ');
}
output.write(content_line.data() + offset, static_cast<std::streamsize>(end - offset));
output << "\r\n";
offset = end;
first = false;
} while (offset < content_line.size());
}
void write_text_property(std::ostream& output, std::string_view name, std::string_view value) {
if (!value.empty()) {
write_folded(output, std::string(name) + ":" + escape_text(value));
}
}
void serialize_calendar(std::ostream& output, std::span<const Event> events) {
write_folded(output, "BEGIN:VCALENDAR");
write_folded(output, "VERSION:2.0");
write_folded(output, "PRODID:-//Nomarchy Linux//nocal 1.0//EN");
write_folded(output, "CALSCALE:GREGORIAN");
for (const Event& event : events) {
write_folded(output, "BEGIN:VEVENT");
write_folded(output, "UID:" + escape_text(event.uid));
if (event.all_day) {
write_folded(output, "DTSTART;VALUE=DATE:" + format_date(event.start));
write_folded(output, "DTEND;VALUE=DATE:" + format_date(event.end));
} else {
write_folded(output, "DTSTART:" + format_utc_time(event.start));
write_folded(output, "DTEND:" + format_utc_time(event.end));
}
write_text_property(output, "SUMMARY", event.title);
write_text_property(output, "LOCATION", event.location);
write_text_property(output, "DESCRIPTION", event.description);
write_text_property(output, "X-NOCAL-CALENDAR-ID", event.calendar_id);
write_text_property(output, "X-NOCAL-COLOR", event.color);
write_folded(output, "END:VEVENT");
}
write_folded(output, "END:VCALENDAR");
}
struct FileSnapshot {
bool existed = false;
std::string bytes;
};
[[nodiscard]] bool same_file_state(
bool expected_existed, const std::string* expected_bytes,
const FileSnapshot& current) noexcept {
if (expected_existed != current.existed) {
return false;
}
if (!expected_existed) {
return true;
}
return expected_bytes != nullptr && *expected_bytes == current.bytes;
}
#if !defined(_WIN32)
[[noreturn]] void throw_io_error(std::string_view operation, const std::filesystem::path& path) {
const int error = errno;
throw std::runtime_error(
std::string(operation) + " '" + path.string() + "': " + std::strerror(error));
}
class FileDescriptor {
public:
explicit FileDescriptor(int descriptor = -1) noexcept : descriptor_(descriptor) {}
FileDescriptor(const FileDescriptor&) = delete;
FileDescriptor& operator=(const FileDescriptor&) = delete;
FileDescriptor(FileDescriptor&& other) noexcept : descriptor_(other.release()) {}
~FileDescriptor() {
if (descriptor_ >= 0) {
::close(descriptor_);
}
}
[[nodiscard]] int get() const noexcept { return descriptor_; }
int release() noexcept {
const int result = descriptor_;
descriptor_ = -1;
return result;
}
private:
int descriptor_;
};
class TemporaryFile {
public:
TemporaryFile(std::filesystem::path path, int descriptor)
: path_(std::move(path)), descriptor_(descriptor) {}
TemporaryFile(const TemporaryFile&) = delete;
TemporaryFile& operator=(const TemporaryFile&) = delete;
TemporaryFile(TemporaryFile&& other) noexcept
: path_(std::move(other.path_)), descriptor_(std::move(other.descriptor_)),
remove_on_destroy_(other.remove_on_destroy_) {
other.remove_on_destroy_ = false;
}
~TemporaryFile() {
if (descriptor_.get() >= 0) {
::close(descriptor_.release());
}
if (remove_on_destroy_) {
std::error_code ignored;
std::filesystem::remove(path_, ignored);
}
}
[[nodiscard]] int descriptor() const noexcept { return descriptor_.get(); }
[[nodiscard]] const std::filesystem::path& path() const noexcept { return path_; }
void close_checked() {
const int descriptor = descriptor_.release();
if (::close(descriptor) != 0) {
throw_io_error("unable to close temporary calendar", path_);
}
}
void committed() noexcept { remove_on_destroy_ = false; }
private:
std::filesystem::path path_;
FileDescriptor descriptor_;
bool remove_on_destroy_ = true;
};
[[nodiscard]] FileSnapshot read_regular_file(
const std::filesystem::path& path, std::string_view purpose) {
const int descriptor = ::open(path.c_str(), O_RDONLY | O_CLOEXEC);
if (descriptor < 0) {
if (errno == ENOENT) {
return {};
}
throw_io_error(purpose, path);
}
const FileDescriptor file(descriptor);
struct stat metadata {};
if (::fstat(file.get(), &metadata) != 0) {
throw_io_error(purpose, path);
}
if (!S_ISREG(metadata.st_mode)) {
throw std::runtime_error(
std::string(purpose) + " '" + path.string() + "': not a regular file");
}
FileSnapshot snapshot;
snapshot.existed = true;
if (metadata.st_size > 0) {
snapshot.bytes.reserve(static_cast<std::size_t>(metadata.st_size));
}
char buffer[16 * 1024];
while (true) {
const ssize_t count = ::read(file.get(), buffer, sizeof(buffer));
if (count < 0) {
if (errno == EINTR) {
continue;
}
throw_io_error(purpose, path);
}
if (count == 0) {
break;
}
snapshot.bytes.append(buffer, static_cast<std::size_t>(count));
}
return snapshot;
}
[[nodiscard]] FileDescriptor lock_destination(const std::filesystem::path& path) {
std::filesystem::path lock_path = path;
lock_path += ".lock";
const int descriptor = ::open(lock_path.c_str(), O_RDWR | O_CREAT | O_CLOEXEC, S_IRUSR | S_IWUSR);
if (descriptor < 0) {
throw_io_error("unable to open calendar lock", lock_path);
}
FileDescriptor lock(descriptor);
while (::flock(lock.get(), LOCK_EX) != 0) {
if (errno != EINTR) {
throw_io_error("unable to lock calendar", lock_path);
}
}
return lock;
}
[[nodiscard]] TemporaryFile create_temporary_file(
const std::filesystem::path& directory, const std::filesystem::path& destination) {
const std::string pattern_string =
(directory / ("." + destination.filename().string() + ".tmp.XXXXXX")).string();
std::vector<char> pattern(pattern_string.begin(), pattern_string.end());
pattern.push_back('\0');
const int descriptor = ::mkstemp(pattern.data());
if (descriptor < 0) {
throw_io_error("unable to create temporary calendar", destination);
}
TemporaryFile temporary{std::filesystem::path{pattern.data()}, descriptor};
const int flags = ::fcntl(descriptor, F_GETFD);
if (flags < 0 || ::fcntl(descriptor, F_SETFD, flags | FD_CLOEXEC) != 0) {
throw_io_error("unable to secure temporary calendar", temporary.path());
}
if (::fchmod(descriptor, S_IRUSR | S_IWUSR) != 0) {
throw_io_error("unable to set temporary calendar permissions", temporary.path());
}
return temporary;
}
void write_all(int descriptor, std::string_view bytes, const std::filesystem::path& path) {
std::size_t offset = 0;
while (offset < bytes.size()) {
const ssize_t written = ::write(descriptor, bytes.data() + offset, bytes.size() - offset);
if (written < 0) {
if (errno == EINTR) {
continue;
}
throw_io_error("unable to write temporary calendar", path);
}
if (written == 0) {
throw std::runtime_error("unable to write temporary calendar '" + path.string()
+ "': write made no progress");
}
offset += static_cast<std::size_t>(written);
}
}
[[nodiscard]] TemporaryFile stage_file(
const std::filesystem::path& directory, const std::filesystem::path& destination,
std::string_view bytes) {
TemporaryFile temporary = create_temporary_file(directory, destination);
write_all(temporary.descriptor(), bytes, temporary.path());
if (::fsync(temporary.descriptor()) != 0) {
throw_io_error("unable to flush temporary calendar", temporary.path());
}
temporary.close_checked();
return temporary;
}
[[nodiscard]] FileDescriptor open_directory(const std::filesystem::path& directory) {
const int directory_descriptor = ::open(directory.c_str(), O_RDONLY | O_DIRECTORY | O_CLOEXEC);
if (directory_descriptor < 0) {
throw_io_error("unable to open calendar directory", directory);
}
return FileDescriptor(directory_descriptor);
}
void replace_with_temporary(
TemporaryFile& temporary, const std::filesystem::path& destination,
std::string_view operation) {
if (::rename(temporary.path().c_str(), destination.c_str()) != 0) {
throw_io_error(operation, destination);
}
temporary.committed();
}
#else
[[nodiscard]] FileSnapshot read_regular_file(
const std::filesystem::path& path, std::string_view purpose) {
std::error_code error;
if (!std::filesystem::exists(path, error)) {
if (!error) {
return {};
}
throw std::runtime_error(
std::string(purpose) + " '" + path.string() + "': " + error.message());
}
if (!std::filesystem::is_regular_file(path, error) || error) {
throw std::runtime_error(
std::string(purpose) + " '" + path.string() + "': not a regular file");
}
std::ifstream input(path, std::ios::binary);
if (!input) {
throw std::runtime_error(std::string(purpose) + " '" + path.string() + "'");
}
FileSnapshot snapshot;
snapshot.existed = true;
snapshot.bytes.assign(std::istreambuf_iterator<char>{input}, {});
if (!input.eof()) {
throw std::runtime_error(std::string(purpose) + " '" + path.string() + "'");
}
return snapshot;
}
#endif
[[noreturn]] void throw_external_change(const std::filesystem::path& path) {
throw std::runtime_error(
"calendar changed externally; reload before saving '" + path.string() + "'");
}
[[nodiscard]] FileSnapshot read_current_file(
const std::filesystem::path& path, bool has_expected_revision) {
try {
return read_regular_file(path, "unable to read current calendar");
} catch (const std::runtime_error&) {
// A file that became unreadable or changed type cannot match an exact
// revision. Keep the optimistic-concurrency diagnostic actionable.
if (has_expected_revision) {
throw_external_change(path);
}
throw;
}
}
} // namespace
LoadResult IcsStore::load(const std::filesystem::path& path) {
LoadResult result;
FileSnapshot snapshot;
try {
snapshot = read_regular_file(path, "unable to read calendar");
} catch (const std::runtime_error&) {
std::error_code error;
result.revision.existed_ = std::filesystem::exists(path, error) && !error;
result.warnings.push_back("unable to open '" + path.string() + "' for reading");
result.safe_to_rewrite = false;
return result;
}
result.revision.existed_ = snapshot.existed;
if (!snapshot.existed) {
return result;
}
result.revision.contents_ = std::make_shared<const std::string>(snapshot.bytes);
std::istringstream input(snapshot.bytes, std::ios::in | std::ios::binary);
const auto lines = unfold(input);
std::optional<PendingEvent> pending;
std::size_t event_start_line = 0;
for (std::size_t index = 0; index < lines.size(); ++index) {
const std::string_view line = lines[index];
const std::size_t separator = content_separator(line);
if (separator == std::string_view::npos) {
if (pending) {
add_warning(result, index + 1, "ignored malformed content line");
}
if (!line.empty()) {
mark_unsafe_to_rewrite(result);
}
continue;
}
const std::string_view head = line.substr(0, separator);
const std::string_view value = line.substr(separator + 1);
const std::size_t semicolon = head.find(';');
const std::string name = upper(head.substr(0, semicolon));
const std::string_view parameters = semicolon == std::string_view::npos
? std::string_view{} : head.substr(semicolon + 1);
if (name == "BEGIN" && upper(value) == "VEVENT") {
if (pending) {
add_warning(result, event_start_line, "nested VEVENT; discarded incomplete event");
mark_unsafe_to_rewrite(result);
}
pending.emplace();
event_start_line = index + 1;
continue;
}
if (name == "END" && upper(value) == "VEVENT") {
if (!pending) {
add_warning(result, index + 1, "END:VEVENT without BEGIN:VEVENT");
mark_unsafe_to_rewrite(result);
continue;
}
if (!pending->uid || pending->uid->empty()) {
add_warning(result, event_start_line, "VEVENT has no UID and was skipped");
mark_unsafe_to_rewrite(result);
} else if (!pending->start) {
add_warning(result, event_start_line, "VEVENT has no valid DTSTART and was skipped");
mark_unsafe_to_rewrite(result);
} else if (pending->end && pending->start->is_date != pending->end->is_date) {
add_warning(result, event_start_line, "DTSTART and DTEND value types differ; event was skipped");
mark_unsafe_to_rewrite(result);
} else {
const bool all_day = pending->start->is_date;
TimePoint end = pending->start->value;
if (pending->end) {
end = pending->end->value;
} else if (all_day) {
const auto next_date = year_month_day{
sys_days{*pending->start->date} + days{1}};
const auto next_midnight = make_local(next_date, 0, 0, 0);
if (!next_midnight) {
add_warning(result, event_start_line,
"could not determine implicit all-day DTEND; event was skipped");
mark_unsafe_to_rewrite(result);
pending.reset();
continue;
}
end = *next_midnight;
}
if (end < pending->start->value || (all_day && end == pending->start->value)) {
add_warning(result, event_start_line, "DTEND is before DTSTART; event was skipped");
mark_unsafe_to_rewrite(result);
} else {
Event event;
event.uid = std::move(*pending->uid);
event.title = std::move(pending->title);
event.start = pending->start->value;
event.end = end;
event.all_day = all_day;
event.location = std::move(pending->location);
event.description = std::move(pending->description);
event.calendar_id = std::move(pending->calendar_id);
event.color = std::move(pending->color);
result.events.push_back(std::move(event));
}
}
pending.reset();
continue;
}
if (!pending) {
const std::string normalized_value = upper(value);
const bool structural = (name == "BEGIN" && normalized_value == "VCALENDAR")
|| (name == "END" && normalized_value == "VCALENDAR");
const bool supported_property = name == "VERSION" || name == "PRODID"
|| name == "CALSCALE";
if ((!structural && !supported_property)
|| (supported_property && !parameters.empty())) {
mark_unsafe_to_rewrite(result);
}
continue;
}
const bool time_property = name == "DTSTART" || name == "DTEND";
const bool text_property = name == "UID" || name == "SUMMARY" || name == "LOCATION"
|| name == "DESCRIPTION" || name == "X-NOCAL-CALENDAR-ID"
|| name == "X-NOCAL-COLOR";
if ((!time_property && !text_property)
|| (time_property && !supported_time_parameters(parameters))
|| (text_property && !parameters.empty())) {
mark_unsafe_to_rewrite(result);
}
if (name == "UID") {
pending->uid = unescape_text(value);
} else if (name == "SUMMARY") {
pending->title = unescape_text(value);
} else if (name == "LOCATION") {
pending->location = unescape_text(value);
} else if (name == "DESCRIPTION") {
pending->description = unescape_text(value);
} else if (name == "X-NOCAL-CALENDAR-ID") {
pending->calendar_id = unescape_text(value);
} else if (name == "X-NOCAL-COLOR") {
pending->color = unescape_text(value);
} else if (name == "DTSTART" || name == "DTEND") {
std::string warning;
auto parsed = parse_time(parameters, value, warning);
if (!parsed) {
add_warning(result, index + 1, std::move(warning));
mark_unsafe_to_rewrite(result);
} else {
if (!warning.empty()) {
add_warning(result, index + 1, std::move(warning));
}
if (name == "DTSTART") {
pending->start = *parsed;
} else {
pending->end = *parsed;
}
}
}
}
if (pending) {
add_warning(result, event_start_line, "unterminated VEVENT was skipped");
mark_unsafe_to_rewrite(result);
}
return result;
}
std::filesystem::path IcsStore::backup_path(const std::filesystem::path& path) {
std::filesystem::path backup = path;
backup += ".bak";
return backup;
}
FileRevision IcsStore::save(
const std::filesystem::path& path, std::span<const Event> events,
std::optional<FileRevision> expected) {
if (path.empty() || path.filename().empty()) {
throw std::invalid_argument("calendar path must name a file");
}
for (const Event& event : events) {
if (event.uid.empty()) {
throw std::invalid_argument("cannot save an event with an empty UID");
}
if (event.end < event.start || (event.all_day && event.end == event.start)) {
throw std::invalid_argument("cannot save event '" + event.uid + "' with an invalid interval");
}
}
std::ostringstream serialized(std::ios::out | std::ios::binary);
serialize_calendar(serialized, events);
if (!serialized) {
throw std::runtime_error("unable to serialize calendar '" + path.string() + "'");
}
std::string bytes = serialized.str();
const std::filesystem::path directory =
path.parent_path().empty() ? std::filesystem::path{"."} : path.parent_path();
std::error_code directory_error;
std::filesystem::create_directories(directory, directory_error);
if (directory_error) {
throw std::runtime_error("unable to create calendar directory '" + directory.string()
+ "': " + directory_error.message());
}
#if !defined(_WIN32)
const FileDescriptor lock = lock_destination(path);
const FileSnapshot current = read_current_file(path, expected.has_value());
if (expected && !same_file_state(
expected->existed_, expected->contents_.get(), current)) {
throw_external_change(path);
}
// Prepare and flush every byte before changing either destination.
TemporaryFile calendar_temporary = stage_file(directory, path, bytes);
std::optional<TemporaryFile> backup_temporary;
const auto backup = backup_path(path);
if (current.existed) {
backup_temporary.emplace(stage_file(directory, backup, current.bytes));
}
const FileDescriptor directory_handle = open_directory(directory);
const FileSnapshot rechecked = read_current_file(path, true);
if (!same_file_state(current.existed, &current.bytes, rechecked)) {
throw_external_change(path);
}
// The advisory lock fully serializes Nocal writers. A program that ignores
// it can still replace the path after this comparison and before rename;
// portable POSIX rename does not provide a true compare-and-swap primitive.
if (backup_temporary) {
replace_with_temporary(*backup_temporary, backup, "unable to replace calendar backup");
(void)::fsync(directory_handle.get());
}
replace_with_temporary(calendar_temporary, path, "unable to replace calendar");
// The calendar rename is the commit point. Reporting a later directory
// fsync failure as an unchanged save would make caller rollback incorrect.
(void)::fsync(directory_handle.get());
#else
const FileSnapshot current = read_current_file(path, expected.has_value());
if (expected && !same_file_state(
expected->existed_, expected->contents_.get(), current)) {
throw_external_change(path);
}
const auto write_replacement = [](const std::filesystem::path& destination,
std::string_view replacement) {
std::filesystem::path temporary = destination;
temporary += ".tmp";
std::ofstream output(temporary, std::ios::binary | std::ios::trunc);
if (!output) {
throw std::runtime_error("unable to open temporary file '" + temporary.string() + "'");
}
output.write(replacement.data(), static_cast<std::streamsize>(replacement.size()));
output.flush();
if (!output) {
throw std::runtime_error("failed while writing temporary file '" + temporary.string() + "'");
}
output.close();
std::error_code ignored;
std::filesystem::remove(destination, ignored);
std::filesystem::rename(temporary, destination);
};
if (current.existed) {
write_replacement(backup_path(path), current.bytes);
}
write_replacement(path, bytes);
#endif
FileRevision revision;
revision.existed_ = true;
revision.contents_ = std::make_shared<const std::string>(std::move(bytes));
return revision;
}
FileRevision IcsStore::restore_backup(
const std::filesystem::path& path, std::optional<FileRevision> expected) {
if (path.empty() || path.filename().empty()) {
throw std::invalid_argument("calendar path must name a file");
}
const std::filesystem::path directory =
path.parent_path().empty() ? std::filesystem::path{"."} : path.parent_path();
const auto backup = backup_path(path);
#if !defined(_WIN32)
const FileDescriptor lock = lock_destination(path);
const FileSnapshot current = read_current_file(path, expected.has_value());
if (expected && !same_file_state(
expected->existed_, expected->contents_.get(), current)) {
throw_external_change(path);
}
const FileSnapshot saved = read_regular_file(backup, "unable to read calendar backup");
if (!saved.existed) {
throw std::runtime_error("calendar backup does not exist '" + backup.string() + "'");
}
TemporaryFile temporary = stage_file(directory, path, saved.bytes);
const FileDescriptor directory_handle = open_directory(directory);
const FileSnapshot rechecked = read_current_file(path, true);
if (!same_file_state(current.existed, &current.bytes, rechecked)) {
throw_external_change(path);
}
replace_with_temporary(temporary, path, "unable to restore calendar backup");
(void)::fsync(directory_handle.get());
#else
const FileSnapshot current = read_current_file(path, expected.has_value());
if (expected && !same_file_state(
expected->existed_, expected->contents_.get(), current)) {
throw_external_change(path);
}
const FileSnapshot saved = read_regular_file(backup, "unable to read calendar backup");
if (!saved.existed) {
throw std::runtime_error("calendar backup does not exist '" + backup.string() + "'");
}
std::ofstream output(path, std::ios::binary | std::ios::trunc);
if (!output) {
throw std::runtime_error("unable to open '" + path.string() + "' for restoring");
}
output.write(saved.bytes.data(), static_cast<std::streamsize>(saved.bytes.size()));
output.flush();
if (!output) {
throw std::runtime_error("failed while restoring '" + path.string() + "'");
}
#endif
FileRevision revision;
revision.existed_ = true;
revision.contents_ = std::make_shared<const std::string>(saved.bytes);
return revision;
}
} // namespace nocal::storage

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

732
src/tui/Screen.cpp Normal file
View File

@@ -0,0 +1,732 @@
#include "nocal/tui/Screen.hpp"
#include <algorithm>
#include <array>
#include <cstdint>
#include <ctime>
#include <map>
#include <sstream>
#include <vector>
namespace nocal::tui {
namespace {
using namespace std::chrono;
constexpr std::array<std::string_view, 12> month_names{
"January", "February", "March", "April", "May", "June",
"July", "August", "September", "October", "November", "December"};
constexpr std::array<std::string_view, 7> weekday_long{
"Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"};
constexpr std::array<std::string_view, 7> weekday_short{
"Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"};
std::uint32_t decode_codepoint(const std::string_view text, const std::size_t at,
std::size_t& length) {
const auto first = static_cast<unsigned char>(text[at]);
if (first < 0x80) {
length = 1;
return first;
}
int continuation = 0;
std::uint32_t result = 0;
if ((first & 0xe0) == 0xc0) {
continuation = 1;
result = first & 0x1f;
} else if ((first & 0xf0) == 0xe0) {
continuation = 2;
result = first & 0x0f;
} else if ((first & 0xf8) == 0xf0) {
continuation = 3;
result = first & 0x07;
} else {
length = 1;
return 0xfffd;
}
if (at + static_cast<std::size_t>(continuation) >= text.size()) {
length = 1;
return 0xfffd;
}
for (int offset = 1; offset <= continuation; ++offset) {
const auto byte = static_cast<unsigned char>(text[at + static_cast<std::size_t>(offset)]);
if ((byte & 0xc0) != 0x80) {
length = 1;
return 0xfffd;
}
result = (result << 6) | (byte & 0x3f);
}
length = static_cast<std::size_t>(continuation + 1);
return result;
}
int codepoint_width(const std::uint32_t value) {
if ((value >= 0x0300 && value <= 0x036f) || (value >= 0xfe00 && value <= 0xfe0f)) {
return 0;
}
if ((value >= 0x1100 && value <= 0x115f) || (value >= 0x2e80 && value <= 0xa4cf) ||
(value >= 0xac00 && value <= 0xd7a3) || (value >= 0xf900 && value <= 0xfaff) ||
(value >= 0x1f300 && value <= 0x1faff)) {
return 2;
}
return 1;
}
int display_width(const std::string_view text) {
int width = 0;
for (std::size_t at = 0; at < text.size();) {
std::size_t length = 1;
const auto cp = decode_codepoint(text, at, length);
width += codepoint_width(cp);
at += length;
}
return width;
}
std::string printable_text(const std::string_view text, const bool preserve_newlines = false) {
std::string result{text};
for (auto& character : result) {
const auto byte = static_cast<unsigned char>(character);
if ((byte < 0x20 && !(preserve_newlines && character == '\n')) || byte == 0x7f) {
character = ' ';
}
}
return result;
}
std::string fit_text(const std::string_view text, const int requested_width,
const bool ellipsis = true) {
const int width = std::max(0, requested_width);
if (width == 0) {
return {};
}
if (display_width(text) <= width) {
std::string result{text};
result.append(static_cast<std::size_t>(width - display_width(text)), ' ');
return result;
}
const int content_limit = std::max(0, width - (ellipsis && width > 1 ? 1 : 0));
std::string result;
int used = 0;
for (std::size_t at = 0; at < text.size();) {
std::size_t length = 1;
const auto cp = decode_codepoint(text, at, length);
const int cp_width = codepoint_width(cp);
if (used + cp_width > content_limit) {
break;
}
result.append(text.substr(at, length));
used += cp_width;
at += length;
}
if (ellipsis && width > 1) {
result += "";
++used;
}
result.append(static_cast<std::size_t>(std::max(0, width - used)), ' ');
return result;
}
std::string centred(const std::string_view text, const int width) {
const int visible = std::min(display_width(text), width);
const int left = std::max(0, (width - visible) / 2);
std::string result(static_cast<std::size_t>(left), ' ');
result += fit_text(text, width - left);
return fit_text(result, width, false);
}
std::string styled(const std::string_view text, const std::string_view code, const bool ansi) {
if (!ansi || code.empty()) {
return std::string{text};
}
return "\x1b[" + std::string{code} + "m" + std::string{text} + "\x1b[0m";
}
std::string two_digits(const unsigned value) {
std::array<char, 3> result{'0', '0', '\0'};
result[0] = static_cast<char>('0' + (value / 10) % 10);
result[1] = static_cast<char>('0' + value % 10);
return std::string{result.data(), 2};
}
std::string iso_date(const year_month_day day) {
return std::to_string(static_cast<int>(day.year())) + "-" +
two_digits(static_cast<unsigned>(day.month())) + "-" +
two_digits(static_cast<unsigned>(day.day()));
}
std::string event_label(const CalendarItem& item, const int width) {
const auto title = item.title.empty() ? std::string{"(untitled)"} : printable_text(item.title);
if (item.all_day || !item.time_of_day) {
return fit_text("" + title, width);
}
const auto total = item.time_of_day->count();
const auto hour = static_cast<unsigned>((total / 60 + 24) % 24);
const auto minute = static_cast<unsigned>((total % 60 + 60) % 60);
if (width < 8) {
return fit_text(title, width);
}
return fit_text(two_digits(hour) + ":" + two_digits(minute) + " " + title, width);
}
std::string event_label(const CalendarItem& item, const int width, const bool focused) {
if (!focused) return event_label(item, width);
if (width <= 2) return fit_text("", width);
auto label = event_label(item, width - 2);
return fit_text(" " + label, width, false);
}
std::string border(const std::vector<int>& widths, const char* left,
const char* join, const char* right) {
std::string result{left};
for (std::size_t column = 0; column < widths.size(); ++column) {
for (int position = 0; position < widths[column]; ++position) {
result += "";
}
result += column + 1 == widths.size() ? right : join;
}
return result;
}
sys_days monday_on_or_before(const sys_days date) {
const weekday day{date};
const auto offset = (day.iso_encoding() + 6) % 7;
return date - days{offset};
}
struct LocalMoment {
year_month_day date;
minutes time_of_day;
};
std::string clock_time(const minutes value) {
const auto total = value.count();
const auto hour = static_cast<unsigned>((total / 60 + 24) % 24);
const auto minute = static_cast<unsigned>((total % 60 + 60) % 60);
return two_digits(hour) + ":" + two_digits(minute);
}
std::string friendly_date(const year_month_day value) {
if (!value.ok()) return {};
const auto weekday_index = weekday{sys_days{value}}.iso_encoding() - 1;
return std::string{weekday_long[weekday_index]} + ", " +
std::to_string(static_cast<unsigned>(value.day())) + " " +
std::string{month_names[static_cast<unsigned>(value.month()) - 1]} + " " +
std::to_string(static_cast<int>(value.year()));
}
std::vector<std::string> wrap_text(const std::string_view text, const int requested_width) {
const int width = std::max(1, requested_width);
std::vector<std::string> result;
std::size_t paragraph_start = 0;
while (paragraph_start <= text.size()) {
const auto paragraph_end = text.find('\n', paragraph_start);
const auto paragraph = text.substr(paragraph_start,
paragraph_end == std::string_view::npos ? text.size() - paragraph_start
: paragraph_end - paragraph_start);
if (paragraph.empty()) {
result.emplace_back();
} else {
std::string line;
std::size_t at = 0;
while (at < paragraph.size()) {
while (at < paragraph.size() && paragraph[at] == ' ') ++at;
const auto word_end = paragraph.find(' ', at);
const auto word = paragraph.substr(at,
word_end == std::string_view::npos ? paragraph.size() - at : word_end - at);
if (!word.empty() && display_width(word) > width) {
if (!line.empty()) {
result.push_back(line);
line.clear();
}
std::size_t word_at = 0;
while (word_at < word.size()) {
std::string part;
int used = 0;
while (word_at < word.size()) {
std::size_t length = 1;
const auto cp = decode_codepoint(word, word_at, length);
const auto cp_width = codepoint_width(cp);
if (used + cp_width > width) {
if (used == 0) {
part.append(word.substr(word_at, length));
word_at += length;
}
break;
}
part.append(word.substr(word_at, length));
word_at += length;
used += cp_width;
}
if (word_at < word.size() || !part.empty()) result.push_back(part);
}
} else if (!word.empty() &&
display_width(line) + (line.empty() ? 0 : 1) + display_width(word) <= width) {
if (!line.empty()) line += ' ';
line += word;
} else if (!word.empty()) {
result.push_back(line);
line = std::string{word};
}
if (word_end == std::string_view::npos) break;
at = word_end + 1;
}
if (!line.empty()) result.push_back(line);
}
if (paragraph_end == std::string_view::npos) break;
paragraph_start = paragraph_end + 1;
}
if (result.empty()) result.emplace_back();
return result;
}
const CalendarItem* focused_item(std::span<const CalendarItem> items, const ScreenState& state) {
if (!state.focused_event_id) return nullptr;
const auto on_selected_day = std::find_if(items.begin(), items.end(), [&](const auto& item) {
return item.id == *state.focused_event_id && item.day == state.selected_day;
});
if (on_selected_day != items.end()) return &*on_selected_day;
const auto anywhere = std::find_if(items.begin(), items.end(), [&](const auto& item) {
return item.id == *state.focused_event_id;
});
return anywhere == items.end() ? nullptr : &*anywhere;
}
bool calendar_item_less(const CalendarItem* left, const CalendarItem* right) {
const auto true_all_day = [](const CalendarItem& item) {
return item.detail_all_day || (item.detail_title.empty() && item.all_day);
};
const bool left_all_day = true_all_day(*left);
const bool right_all_day = true_all_day(*right);
if (left_all_day != right_all_day) return left_all_day;
const auto left_time = left->detail_start_time_of_day
? left->detail_start_time_of_day : left->time_of_day;
const auto right_time = right->detail_start_time_of_day
? right->detail_start_time_of_day : right->time_of_day;
return left_time.value_or(minutes{-1}) < right_time.value_or(minutes{-1});
}
std::string detail_frame(std::span<const CalendarItem> items, const ScreenState& state,
const CalendarItem& focused) {
const int width = std::max(1, state.width);
const int height = std::max(1, state.height);
if (width < 4 || height < 4) {
std::vector<std::string> tiny(static_cast<std::size_t>(height), std::string{});
const auto& tiny_title = focused.detail_title.empty() ? focused.title : focused.detail_title;
tiny[0] = fit_text(tiny_title.empty() ? "(untitled)" : printable_text(tiny_title), width);
std::ostringstream output;
for (int line = 0; line < height; ++line) {
if (line != 0) output << '\n';
output << tiny[static_cast<std::size_t>(line)];
}
return output.str();
}
std::vector<const CalendarItem*> selected_items;
for (const auto& item : items) {
if (item.day == state.selected_day) selected_items.push_back(&item);
}
std::stable_sort(selected_items.begin(), selected_items.end(), calendar_item_less);
const auto current = std::find_if(selected_items.begin(), selected_items.end(),
[&](const auto* item) { return item->id == focused.id; });
const auto ordinal = current == selected_items.end()
? 1U : static_cast<unsigned>(current - selected_items.begin() + 1);
const auto total = std::max<std::size_t>(1, selected_items.size());
const int inner = width - 2;
std::vector<std::pair<std::string, std::string>> content;
const auto add = [&](std::string text, std::string style = {}) {
content.emplace_back(fit_text(text, inner, false), std::move(style));
};
add(" APPOINTMENT", "2;1");
const auto& title = focused.detail_title.empty() ? focused.title : focused.detail_title;
add(" " + (title.empty() ? std::string{"(untitled)"} : printable_text(title)), "1");
add("");
const auto start_day = focused.start_day.ok() ? focused.start_day : focused.day;
const auto end_day = focused.end_day.ok() ? focused.end_day : start_day;
add(" Date " + friendly_date(start_day));
if (end_day != start_day) add(" Ends " + friendly_date(end_day));
const bool event_all_day = focused.detail_all_day ||
(focused.detail_title.empty() && focused.all_day);
const auto start_time = focused.detail_start_time_of_day
? focused.detail_start_time_of_day : focused.time_of_day;
if (event_all_day || !start_time) {
add(" Time All day");
} else {
auto value = clock_time(*start_time);
if (focused.end_time_of_day) value += " " + clock_time(*focused.end_time_of_day);
add(" Time " + value + " local");
}
if (!focused.location.empty()) add(" Location " + printable_text(focused.location));
add("");
if (!focused.description.empty()) {
add(" NOTES", "2;1");
for (const auto& line : wrap_text(printable_text(focused.description, true),
std::max(1, inner - 2))) {
add(" " + line);
}
} else {
add(" No notes for this appointment.", "2;3");
}
const auto footer = " ↑↓/Tab browse e edit d delete u undo Ctrl-R redo Esc back " +
std::to_string(ordinal) + "/" + std::to_string(total) + " ";
const int body_rows = height - 2;
std::ostringstream output;
output << border({inner}, "", "", "") << '\n';
for (int line = 0; line < body_rows; ++line) {
std::string value(static_cast<std::size_t>(inner), ' ');
std::string style;
if (line == body_rows - 1) {
value = fit_text(footer, inner);
style = "7";
} else if (line < static_cast<int>(content.size())) {
value = content[static_cast<std::size_t>(line)].first;
style = content[static_cast<std::size_t>(line)].second;
}
output << "" << styled(value, style, state.ansi) << "\n";
}
output << border({inner}, "", "", "");
return output.str();
}
std::string delete_confirmation_frame(const ScreenState& state, const CalendarItem& focused) {
const int width = std::max(1, state.width);
const int height = std::max(1, state.height);
if (width < 4 || height < 4) {
return fit_text("Delete? y/n", width);
}
const int inner = width - 2;
const auto& detail_title = focused.detail_title.empty() ? focused.title : focused.detail_title;
const auto title = detail_title.empty() ? std::string{"(untitled)"}
: printable_text(detail_title);
const auto question = fit_text(" Delete “" + title + "”?", inner);
const auto controls = fit_text(" y confirm n/Esc cancel", inner);
const int question_line = std::max(1, height / 2 - 1);
std::ostringstream output;
output << border({inner}, "", "", "") << '\n';
for (int line = 0; line < height - 2; ++line) {
std::string content(static_cast<std::size_t>(inner), ' ');
std::string style;
if (line == question_line) {
content = question;
style = "1;31";
} else if (line == question_line + 2) {
content = controls;
style = "7";
} else if (!state.notification.empty() && line == question_line + 3) {
content = fit_text(" " + printable_text(state.notification, true), inner);
style = state.notification_is_error ? "1;31" : "1;32";
}
output << "" << styled(content, style, state.ansi) << "\n";
}
output << border({inner}, "", "", "");
return output.str();
}
LocalMoment to_local(const system_clock::time_point point) {
const auto value = system_clock::to_time_t(point);
std::tm local{};
::localtime_r(&value, &local);
return {
year{local.tm_year + 1900} / month{static_cast<unsigned>(local.tm_mon + 1)} /
day{static_cast<unsigned>(local.tm_mday)},
hours{local.tm_hour} + minutes{local.tm_min},
};
}
std::string compact_frame(std::span<const CalendarItem> items, const ScreenState& state) {
const int width = std::max(1, state.width);
const int height = std::max(1, state.height);
const auto first = sys_days{state.visible_month / day{1}};
const auto grid_start = monday_on_or_before(first);
std::vector<std::string> lines;
const auto title = std::string{month_names[static_cast<unsigned>(state.visible_month.month()) - 1]} +
" " + std::to_string(static_cast<int>(state.visible_month.year()));
lines.push_back(styled(centred(title, width), "1", state.ansi));
std::array<int, 7> tokens{};
for (int column = 0; column < 7; ++column) {
tokens[static_cast<std::size_t>(column)] =
width / 7 + (column < width % 7 ? 1 : 0);
}
std::string weekdays;
for (std::size_t column = 0; column < weekday_short.size(); ++column) {
weekdays += centred(weekday_short[column], tokens[column]);
}
lines.push_back(styled(weekdays, "2;1", state.ansi));
for (int week = 0; week < 6; ++week) {
std::string week_line;
for (int column = 0; column < 7; ++column) {
const auto date = year_month_day{grid_start + days{week * 7 + column}};
const auto count = std::count_if(items.begin(), items.end(),
[date](const auto& item) { return item.day == date; });
std::string label = std::to_string(static_cast<unsigned>(date.day()));
const auto token = tokens[static_cast<std::size_t>(column)];
if (count > 0 && token >= 3) {
label += "";
}
auto text = centred(label, token);
if (date == state.selected_day) {
text = styled(text, date == state.today ? "7;1;36" : "7;1", state.ansi);
} else if (date == state.today) {
text = styled(text, "1;36", state.ansi);
} else if (date.month() != state.visible_month.month()) {
text = styled(text, "2", state.ansi);
}
week_line += text;
}
lines.push_back(std::move(week_line));
}
const auto focused = focused_item(items, state);
if (height > 9 && focused != nullptr) {
const auto focus_title = focused->title.empty() ? std::string{"(untitled)"}
: printable_text(focused->title);
lines.push_back(styled(fit_text(" " + focus_title, width),
"7;1", state.ansi));
}
while (static_cast<int>(lines.size()) < height - 1) {
lines.emplace_back(static_cast<std::size_t>(width), ' ');
}
std::string controls;
if (!state.notification.empty()) {
controls = printable_text(state.notification, true);
} else if (focused == nullptr) {
controls = "a add u undo Ctrl-R redo ? help n/p month t today q quit";
} else {
controls = "Tab appointment Enter open e edit d delete u undo Ctrl-R redo";
}
if (static_cast<int>(lines.size()) < height) {
const auto style = state.notification.empty() ? "2"
: state.notification_is_error ? "1;31" : "1;32";
lines.push_back(styled(fit_text(controls, width), style, state.ansi));
}
if (static_cast<int>(lines.size()) > height) lines.resize(static_cast<std::size_t>(height));
std::ostringstream output;
for (int line = 0; line < height; ++line) {
if (line != 0) output << '\n';
output << lines[static_cast<std::size_t>(line)];
}
return output.str();
}
} // namespace
Action decode_key(const std::string_view sequence) noexcept {
if (sequence == "h" || sequence == "\x1b[D" || sequence == "\x1bOD") return Action::left;
if (sequence == "l" || sequence == "\x1b[C" || sequence == "\x1bOC") return Action::right;
if (sequence == "k" || sequence == "\x1b[A" || sequence == "\x1bOA") return Action::up;
if (sequence == "j" || sequence == "\x1b[B" || sequence == "\x1bOB") return Action::down;
if (sequence == "n" || sequence == "\x1b[6~") return Action::next_month;
if (sequence == "p" || sequence == "\x1b[5~") return Action::previous_month;
if (sequence == "t") return Action::today;
if (sequence == "?") return Action::toggle_help;
if (sequence == "\x1b[Z") return Action::previous_event;
if (sequence == "\t" || sequence == "]") return Action::next_event;
if (sequence == "[") return Action::previous_event;
if (sequence == "\r" || sequence == "\n") return Action::select;
if (sequence == "\x1b" || sequence == "\x7f") return Action::back;
if (sequence == "a") return Action::add_event;
if (sequence == "e") return Action::edit_event;
if (sequence == "d") return Action::delete_event;
if (sequence == "u") return Action::undo;
if (sequence == "\x12") return Action::redo;
if (sequence == "q" || sequence == "Q" || sequence == "\x03") return Action::quit;
return Action::none;
}
std::string render_month(std::span<const CalendarItem> items, const ScreenState& state) {
const auto focused = focused_item(items, state);
if (state.confirm_delete && focused != nullptr) {
return delete_confirmation_frame(state, *focused);
}
if (state.show_event_details && focused != nullptr) {
return detail_frame(items, state, *focused);
}
if (state.width < 35 || state.height < 16) {
return compact_frame(items, state);
}
const int width = state.width;
const int inner_total = width - 8; // eight vertical border glyphs
std::vector<int> widths(7, inner_total / 7);
for (int column = 0; column < inner_total % 7; ++column) {
++widths[static_cast<std::size_t>(column)];
}
const int content_rows = std::max(6, state.height - 10);
std::vector<int> row_heights(6, content_rows / 6);
for (int row = 0; row < content_rows % 6; ++row) {
++row_heights[static_cast<std::size_t>(row)];
}
const auto first = sys_days{state.visible_month / day{1}};
const auto grid_start = monday_on_or_before(first);
std::map<sys_days, std::vector<const CalendarItem*>> by_day;
for (const auto& item : items) {
if (item.day.ok()) {
by_day[sys_days{item.day}].push_back(&item);
}
}
for (auto& [_, day_items] : by_day) {
std::stable_sort(day_items.begin(), day_items.end(), calendar_item_less);
}
const auto month_index = static_cast<unsigned>(state.visible_month.month()) - 1;
const auto title = " " + std::string{month_names[month_index]} + " " +
std::to_string(static_cast<int>(state.visible_month.year())) + " ";
std::ostringstream output;
output << styled(centred(title, width), "1", state.ansi) << '\n';
output << ' ';
for (std::size_t column = 0; column < widths.size(); ++column) {
const auto name = widths[column] >= 9 ? weekday_long[column] : weekday_short[column];
output << styled(centred(name, widths[column]), "2;1", state.ansi);
output << (column + 1 == widths.size() ? ' ' : ' ');
}
output << '\n' << border(widths, "", "", "") << '\n';
for (int week = 0; week < 6; ++week) {
for (int line = 0; line < row_heights[static_cast<std::size_t>(week)]; ++line) {
output << "";
for (int column = 0; column < 7; ++column) {
const auto date_days = grid_start + days{week * 7 + column};
const auto date = year_month_day{date_days};
const auto cell_width = widths[static_cast<std::size_t>(column)];
std::string cell;
std::string style;
if (line == 0) {
cell = " " + std::to_string(static_cast<unsigned>(date.day()));
cell = fit_text(cell, cell_width, false);
if (date == state.selected_day) style = date == state.today ? "7;1;36" : "7;1";
else if (date == state.today) style = "1;36";
else if (date.month() != state.visible_month.month()) style = "2";
} else {
const auto found = by_day.find(date_days);
const auto count = found == by_day.end() ? 0U : found->second.size();
const auto appointment_line = static_cast<std::size_t>(line - 1);
const auto slots = static_cast<std::size_t>(row_heights[static_cast<std::size_t>(week)] - 1);
const bool focused_on_day = found != by_day.end() && date == state.selected_day &&
state.focused_event_id &&
std::any_of(found->second.begin(), found->second.end(),
[&](const auto* item) {
return item->id == *state.focused_event_id;
});
// With only one appointment row, focus is more useful than
// an overflow counter. Taller cells retain both.
const bool show_overflow = count > slots &&
!(slots == 1 && focused_on_day);
const auto event_capacity = show_overflow && slots > 0 ? slots - 1 : slots;
std::size_t window_start = 0;
if (found != by_day.end() && event_capacity > 0 && date == state.selected_day &&
state.focused_event_id) {
const auto focus = std::find_if(found->second.begin(), found->second.end(),
[&](const auto* item) { return item->id == *state.focused_event_id; });
if (focus != found->second.end()) {
const auto focus_index = static_cast<std::size_t>(focus - found->second.begin());
if (focus_index >= event_capacity) window_start = focus_index - event_capacity + 1;
}
}
if (show_overflow && appointment_line + 1 == slots) {
cell = fit_text(" +" + std::to_string(count - event_capacity) + " hidden", cell_width);
style = "2";
} else if (appointment_line < event_capacity &&
window_start + appointment_line < count) {
const auto* item = found->second[window_start + appointment_line];
const bool is_focused = date == state.selected_day && state.focused_event_id &&
item->id == *state.focused_event_id;
cell = event_label(*item, cell_width, is_focused);
if (is_focused) style = "7;1";
} else {
cell.assign(static_cast<std::size_t>(cell_width), ' ');
}
if (date.month() != state.visible_month.month() && style.empty()) style = "2";
}
output << styled(cell, style, state.ansi) << "";
}
output << '\n';
}
output << (week == 5 ? border(widths, "", "", "")
: border(widths, "", "", ""))
<< '\n';
}
const auto selected_count = by_day.contains(sys_days{state.selected_day})
? by_day.at(sys_days{state.selected_day}).size() : 0U;
std::string status;
if (state.show_help) {
status = " arrows/hjkl day Tab/⇧Tab appointment Enter read a add e edit d delete u undo Ctrl-R redo n/p month t today ? close q quit";
} else if (!state.notification.empty()) {
status = " " + printable_text(state.notification, true);
} else if (focused != nullptr) {
const auto focus_title = focused->title.empty() ? std::string{"(untitled)"}
: printable_text(focused->title);
status = " " + iso_date(state.selected_day) + " " +
focus_title + " Tab next Enter read e edit d delete u undo Ctrl-R redo";
} else {
status = " " + iso_date(state.selected_day) + " " + std::to_string(selected_count) +
(selected_count == 1 ? " appointment" : " appointments") +
(selected_count > 0 ? " Tab focus" : "") +
" a add u undo Ctrl-R redo ? help q quit";
}
const auto status_style = !state.notification.empty()
? (state.notification_is_error ? "1;31" : "1;32")
: focused != nullptr ? "7" : "2";
output << styled(fit_text(status, width), status_style, state.ansi);
return output.str();
}
std::string render_month(const std::span<const Event> events, const ScreenState& state) {
std::vector<CalendarItem> items;
items.reserve(events.size() * 2);
std::map<std::string, std::size_t> uid_counts;
for (const auto& event : events) {
if (!event.uid.empty()) ++uid_counts[event.uid];
}
const auto visible_start = monday_on_or_before(sys_days{state.visible_month / day{1}});
const auto visible_end = visible_start + days{41};
for (std::size_t index = 0; index < events.size(); ++index) {
const auto& event = events[index];
const auto identity = !event.uid.empty() && uid_counts[event.uid] == 1
? event.uid : "@nocal:" + std::to_string(index);
const auto local_start = to_local(event.start);
const auto local_finish = to_local(event.end);
const auto one_second = duration_cast<system_clock::duration>(seconds{1});
const auto final_instant = event.end > event.start
? event.end - std::min(event.end - event.start, one_second)
: event.start;
const auto local_end = to_local(final_instant);
const auto event_start = sys_days{local_start.date};
const auto event_last = sys_days{local_end.date};
const auto first_day = std::max(event_start, visible_start);
const auto last_day = std::min(event_last, visible_end);
for (auto date = first_day; date <= last_day; date += days{1}) {
const bool continuation = date != event_start;
items.push_back(CalendarItem{
.id = identity,
.title = continuation ? "" + event.title : event.title,
.day = year_month_day{date},
.time_of_day = event.all_day || continuation
? std::nullopt
: std::optional{local_start.time_of_day},
.all_day = event.all_day || continuation,
.end_time_of_day = event.all_day ? std::nullopt
: std::optional{local_finish.time_of_day},
.start_day = local_start.date,
.end_day = event.all_day ? local_end.date : local_finish.date,
.location = event.location,
.description = event.description,
.detail_title = event.title,
.detail_all_day = event.all_day,
.detail_start_time_of_day = event.all_day
? std::nullopt
: std::optional{local_start.time_of_day},
});
}
}
return render_month(std::span<const CalendarItem>{items}, state);
}
} // namespace nocal::tui

112
src/tui/Terminal.cpp Normal file
View File

@@ -0,0 +1,112 @@
#include "nocal/tui/Terminal.hpp"
#include <cerrno>
#include <csignal>
#include <cstddef>
#include <sys/ioctl.h>
#include <unistd.h>
namespace nocal::tui {
namespace {
volatile std::sig_atomic_t resize_pending = 0;
extern "C" void mark_resize(int) noexcept {
resize_pending = 1;
}
} // namespace
struct ResizeWatcher::sigaction_storage {
struct sigaction previous {};
bool installed{false};
};
TerminalSize terminal_size(const int fd) noexcept {
struct winsize size {};
if (::ioctl(fd, TIOCGWINSZ, &size) == 0 && size.ws_col > 0 && size.ws_row > 0) {
return {static_cast<int>(size.ws_col), static_cast<int>(size.ws_row)};
}
return {};
}
bool write_terminal(const int fd, const std::string_view bytes) noexcept {
std::size_t written = 0;
while (written < bytes.size()) {
const auto result = ::write(fd, bytes.data() + written, bytes.size() - written);
if (result > 0) {
written += static_cast<std::size_t>(result);
continue;
}
if (result < 0 && errno == EINTR) {
continue;
}
return false;
}
return true;
}
AlternateScreen::AlternateScreen(const int output_fd)
: output_fd_(output_fd), active_(::isatty(output_fd) != 0) {
if (active_) {
// Save/restore the title too: popup terminals in Hyprland often retain it.
(void)write_terminal(output_fd_, "\x1b[?1049h\x1b[?25l\x1b[0m");
}
}
AlternateScreen::~AlternateScreen() {
if (active_) {
(void)write_terminal(output_fd_, "\x1b[0m\x1b[?25h\x1b[?1049l");
}
}
RawInput::RawInput(const int input_fd) : input_fd_(input_fd) {
if (::isatty(input_fd_) == 0 || ::tcgetattr(input_fd_, &original_) != 0) {
return;
}
auto raw = original_;
raw.c_iflag &= static_cast<tcflag_t>(~(BRKINT | ICRNL | INPCK | ISTRIP | IXON));
// Leave output processing enabled: frames use '\n' and benefit from the
// terminal's normal newline mapping.
raw.c_cflag |= CS8;
// Deliver Ctrl-C as input so TuiApp can unwind and reliably restore the
// alternate screen and cursor before exiting.
raw.c_lflag &= static_cast<tcflag_t>(~(ECHO | ICANON | IEXTEN | ISIG));
raw.c_cc[VMIN] = 0;
raw.c_cc[VTIME] = 0;
active_ = ::tcsetattr(input_fd_, TCSAFLUSH, &raw) == 0;
}
RawInput::~RawInput() {
if (active_) {
::tcsetattr(input_fd_, TCSAFLUSH, &original_);
}
}
ResizeWatcher::ResizeWatcher() : storage_(new sigaction_storage) {
resize_pending = 1;
struct sigaction action {};
action.sa_handler = mark_resize;
::sigemptyset(&action.sa_mask);
action.sa_flags = 0; // Ensure a blocked poll wakes up for resize.
storage_->installed = ::sigaction(SIGWINCH, &action, &storage_->previous) == 0;
}
ResizeWatcher::~ResizeWatcher() {
if (storage_->installed) {
::sigaction(SIGWINCH, &storage_->previous, nullptr);
}
delete storage_;
}
bool ResizeWatcher::consume() noexcept {
if (resize_pending == 0) {
return false;
}
resize_pending = 0;
return true;
}
} // namespace nocal::tui

573
src/tui/TuiApp.cpp Normal file
View File

@@ -0,0 +1,573 @@
#include "nocal/tui/TuiApp.hpp"
#include "nocal/domain/event_query.hpp"
#include "nocal/tui/Terminal.hpp"
#include <algorithm>
#include <array>
#include <cerrno>
#include <chrono>
#include <cstdlib>
#include <ctime>
#include <exception>
#include <string>
#include <utility>
#include <poll.h>
#include <unistd.h>
namespace nocal::tui {
namespace {
using namespace std::chrono;
year_month_day current_day() {
const auto now = system_clock::to_time_t(system_clock::now());
std::tm local{};
::localtime_r(&now, &local);
return year{local.tm_year + 1900} / month{static_cast<unsigned>(local.tm_mon + 1)} /
day{static_cast<unsigned>(local.tm_mday)};
}
year_month_day clamp_to_month(const year_month month, const unsigned requested_day) {
const auto last_day = static_cast<unsigned>((month / std::chrono::last).day());
return month / day{std::min(requested_day, last_day)};
}
std::size_t complete_sequence_length(const std::string& input) {
if (input.empty()) return 0;
if (input.front() != '\x1b') return 1;
if (input.size() == 1) return 0;
if (input[1] == 'O') return input.size() >= 3 ? 3 : 0;
if (input[1] != '[') return 1;
if (input.size() < 3) return 0;
if ((input[2] >= 'A' && input[2] <= 'D') || input[2] == 'Z') return 3;
if ((input[2] == '5' || input[2] == '6') && input.size() >= 4 && input[3] == '~') return 4;
return 1;
}
std::string event_identity(const std::span<const Event> events, const std::size_t index) {
const auto& uid = events[index].uid;
const bool unique = !uid.empty() &&
std::count_if(events.begin(), events.end(), [&uid](const Event& event) {
return event.uid == uid;
}) == 1;
return unique ? uid : "@nocal:" + std::to_string(index);
}
std::size_t source_index(const std::span<const Event> events, const Event& event) {
const auto found = std::find_if(events.begin(), events.end(), [&event](const Event& candidate) {
return &candidate == &event;
});
return static_cast<std::size_t>(std::distance(events.begin(), found));
}
} // namespace
TuiApp::TuiApp(std::vector<Event>& events, const int input_fd, const int output_fd)
: TuiApp(events, SaveCallback{}, input_fd, output_fd) {}
TuiApp::TuiApp(std::vector<Event>& events, SaveCallback saver, const int input_fd,
const int output_fd)
: events_(events), input_fd_(input_fd), output_fd_(output_fd), saver_(std::move(saver)) {
undo_history_.reserve(history_limit);
redo_history_.reserve(history_limit);
state_.today = current_day();
state_.selected_day = state_.today;
state_.visible_month = state_.today.year() / state_.today.month();
}
std::optional<std::size_t> TuiApp::focused_event_index() const {
if (!state_.focused_event_id) return std::nullopt;
const auto event_span = std::span<const Event>{events_};
for (std::size_t index = 0; index < events_.size(); ++index) {
if (event_identity(event_span, index) == *state_.focused_event_id) return index;
}
return std::nullopt;
}
void TuiApp::set_notification(std::string message, const bool error) {
state_.notification = std::move(message);
state_.notification_is_error = error;
}
TuiApp::HistorySnapshot TuiApp::capture_history_snapshot() const {
return {
.events = events_,
.visible_month = state_.visible_month,
.selected_day = state_.selected_day,
.focused_event_id = state_.focused_event_id,
.show_event_details = state_.show_event_details,
.show_help = state_.show_help,
};
}
void TuiApp::restore_history_snapshot(HistorySnapshot snapshot) {
events_ = std::move(snapshot.events);
state_.visible_month = snapshot.visible_month;
state_.selected_day = snapshot.selected_day;
state_.focused_event_id = std::move(snapshot.focused_event_id);
state_.show_event_details = snapshot.show_event_details;
state_.show_help = snapshot.show_help;
state_.confirm_delete = false;
}
void TuiApp::push_history(std::vector<HistoryEntry>& history, HistoryEntry entry) {
if (history.size() == history_limit) history.erase(history.begin());
history.push_back(std::move(entry));
}
bool TuiApp::persist(const std::span<const Event> events) {
try {
if (saver_) saver_(events);
return true;
} catch (const std::exception& error) {
set_notification("Could not save changes: " + std::string{error.what()}, true);
} catch (...) {
set_notification("Could not save changes: unknown error", true);
}
return false;
}
void TuiApp::record_mutation(std::string operation) {
if (pending_mutation_before_) {
push_history(undo_history_,
{.snapshot = std::move(*pending_mutation_before_),
.operation = std::move(operation)});
}
pending_mutation_before_.reset();
redo_history_.clear();
}
void TuiApp::undo() {
if (undo_history_.empty()) {
set_notification("Nothing to undo.", true);
return;
}
auto current = capture_history_snapshot();
const auto operation = undo_history_.back().operation;
if (!persist(std::span<const Event>{undo_history_.back().snapshot.events})) return;
auto target = std::move(undo_history_.back());
undo_history_.pop_back();
push_history(redo_history_,
{.snapshot = std::move(current), .operation = target.operation});
restore_history_snapshot(std::move(target.snapshot));
set_notification("Undid " + operation + ".");
}
void TuiApp::redo() {
if (redo_history_.empty()) {
set_notification("Nothing to redo.", true);
return;
}
auto current = capture_history_snapshot();
const auto operation = redo_history_.back().operation;
if (!persist(std::span<const Event>{redo_history_.back().snapshot.events})) return;
auto target = std::move(redo_history_.back());
redo_history_.pop_back();
push_history(undo_history_,
{.snapshot = std::move(current), .operation = target.operation});
restore_history_snapshot(std::move(target.snapshot));
set_notification("Redid " + operation + ".");
}
void TuiApp::begin_add() {
pending_mutation_before_ = capture_history_snapshot();
editor_.emplace(state_.selected_day);
editing_index_.reset();
state_.show_event_details = false;
state_.show_help = false;
state_.confirm_delete = false;
}
void TuiApp::begin_edit() {
const auto index = focused_event_index();
if (!index) {
set_notification("Focus an appointment before editing.", true);
return;
}
pending_mutation_before_ = capture_history_snapshot();
editor_.emplace(events_[*index]);
editing_index_ = index;
state_.show_event_details = false;
state_.show_help = false;
state_.confirm_delete = false;
}
void TuiApp::begin_delete() {
if (!focused_event_index()) {
set_notification("Focus an appointment before deleting.", true);
return;
}
pending_mutation_before_ = capture_history_snapshot();
state_.confirm_delete = true;
state_.show_event_details = false;
state_.show_help = false;
}
void TuiApp::submit_editor() {
if (!editor_) return;
auto original = events_;
const auto original_focus = state_.focused_event_id;
std::size_t changed_index = events_.size();
std::string success;
const bool editing = editing_index_ && *editing_index_ < events_.size();
if (editing) {
changed_index = *editing_index_;
auto replacement = editor_->result();
replacement.uid = events_[changed_index].uid;
events_[changed_index] = std::move(replacement);
success = "Appointment updated.";
} else {
events_.push_back(editor_->result());
changed_index = events_.size() - 1;
success = "Appointment added.";
}
if (!persist(std::span<const Event>{events_})) {
events_.swap(original);
state_.focused_event_id = original_focus;
editor_.reset();
editing_index_.reset();
pending_mutation_before_.reset();
return;
}
editor_.reset();
editing_index_.reset();
state_.selected_day = local_date(events_[changed_index].start);
state_.visible_month = state_.selected_day.year() / state_.selected_day.month();
state_.focused_event_id = event_identity(std::span<const Event>{events_}, changed_index);
state_.show_event_details = false;
record_mutation(editing ? "edit appointment" : "add appointment");
set_notification(std::move(success));
}
void TuiApp::confirm_delete() {
const auto index = focused_event_index();
if (!index) {
state_.confirm_delete = false;
pending_mutation_before_.reset();
set_notification("The focused appointment no longer exists.", true);
return;
}
auto original = events_;
const auto original_focus = state_.focused_event_id;
events_.erase(events_.begin() + static_cast<std::ptrdiff_t>(*index));
if (!persist(std::span<const Event>{events_})) {
events_.swap(original);
state_.focused_event_id = original_focus;
state_.confirm_delete = false;
pending_mutation_before_.reset();
return;
}
state_.focused_event_id.reset();
state_.show_event_details = false;
state_.confirm_delete = false;
record_mutation("delete appointment");
set_notification("Appointment deleted.");
}
void TuiApp::cancel_delete() {
state_.confirm_delete = false;
pending_mutation_before_.reset();
set_notification("Deletion cancelled.");
}
void TuiApp::handle_input(const std::string_view input) {
if (input == "\x03") {
dispatch(Action::quit);
return;
}
if (editor_) {
if (input == "\x12") {
set_notification("Finish or cancel editing before redoing.", true);
return;
}
const auto outcome = editor_->handle_key(input);
if (outcome == EditorOutcome::submit) {
submit_editor();
} else if (outcome == EditorOutcome::cancel) {
editor_.reset();
editing_index_.reset();
pending_mutation_before_.reset();
set_notification("Edit cancelled.");
}
return;
}
if (state_.confirm_delete) {
if (input == "y" || input == "Y") {
confirm_delete();
} else if (input == "n" || input == "N" || input == "\x1b" || input == "\x7f") {
cancel_delete();
} else if (input == "u" || input == "\x12") {
set_notification("Finish or cancel deletion before using history.", true);
}
return;
}
dispatch(decode_key(input));
}
void TuiApp::dispatch(const Action action) {
if (action != Action::none) {
state_.notification.clear();
state_.notification_is_error = false;
}
if (editor_) {
if (action == Action::back) {
editor_.reset();
editing_index_.reset();
pending_mutation_before_.reset();
set_notification("Edit cancelled.");
} else if (action == Action::undo || action == Action::redo) {
set_notification("Finish or cancel editing before using history.", true);
} else if (action == Action::quit) {
running_ = false;
}
return;
}
if (state_.confirm_delete) {
if (action == Action::back) cancel_delete();
else if (action == Action::undo || action == Action::redo) {
set_notification("Finish or cancel deletion before using history.", true);
}
else if (action == Action::quit) running_ = false;
return;
}
const auto clear_event_focus = [this] {
state_.focused_event_id.reset();
state_.show_event_details = false;
};
const auto cycle_event = [this](const int direction) {
const auto event_span = std::span<const Event>{events_};
const auto day_events = events_on_day(event_span, state_.selected_day);
if (day_events.empty()) {
state_.focused_event_id.reset();
return;
}
std::size_t current = day_events.size();
if (state_.focused_event_id) {
for (std::size_t index = 0; index < day_events.size(); ++index) {
const auto event_index = source_index(event_span, day_events[index].get());
if (event_identity(event_span, event_index) == *state_.focused_event_id) {
current = index;
break;
}
}
}
std::size_t target = 0;
if (current == day_events.size()) {
target = direction < 0 ? day_events.size() - 1 : 0;
} else if (direction < 0) {
target = current == 0 ? day_events.size() - 1 : current - 1;
} else {
target = (current + 1) % day_events.size();
}
const auto event_index = source_index(event_span, day_events[target].get());
state_.focused_event_id = event_identity(event_span, event_index);
};
if (state_.show_event_details) {
switch (action) {
case Action::next_event:
case Action::right:
case Action::down:
cycle_event(1);
return;
case Action::previous_event:
case Action::left:
case Action::up:
cycle_event(-1);
return;
case Action::select:
case Action::back:
state_.show_event_details = false;
return;
case Action::add_event:
begin_add();
return;
case Action::edit_event:
begin_edit();
return;
case Action::delete_event:
begin_delete();
return;
case Action::undo:
undo();
return;
case Action::redo:
redo();
return;
case Action::quit:
running_ = false;
return;
case Action::none:
case Action::previous_month:
case Action::next_month:
case Action::today:
case Action::toggle_help:
return;
}
}
auto selected = sys_days{state_.selected_day};
switch (action) {
case Action::left:
clear_event_focus();
selected -= days{1};
break;
case Action::right:
clear_event_focus();
selected += days{1};
break;
case Action::up:
clear_event_focus();
selected -= days{7};
break;
case Action::down:
clear_event_focus();
selected += days{7};
break;
case Action::previous_month: {
clear_event_focus();
const auto target = state_.visible_month - months{1};
state_.selected_day = clamp_to_month(target, static_cast<unsigned>(state_.selected_day.day()));
state_.visible_month = target;
return;
}
case Action::next_month: {
clear_event_focus();
const auto target = state_.visible_month + months{1};
state_.selected_day = clamp_to_month(target, static_cast<unsigned>(state_.selected_day.day()));
state_.visible_month = target;
return;
}
case Action::today:
clear_event_focus();
state_.today = current_day();
state_.selected_day = state_.today;
state_.visible_month = state_.today.year() / state_.today.month();
return;
case Action::toggle_help:
state_.show_help = !state_.show_help;
return;
case Action::previous_event:
cycle_event(-1);
return;
case Action::next_event:
cycle_event(1);
return;
case Action::select:
if (!state_.focused_event_id) {
cycle_event(1);
}
if (state_.focused_event_id) {
state_.show_event_details = true;
state_.show_help = false;
}
return;
case Action::back:
clear_event_focus();
state_.show_help = false;
return;
case Action::add_event:
begin_add();
return;
case Action::edit_event:
begin_edit();
return;
case Action::delete_event:
begin_delete();
return;
case Action::undo:
undo();
return;
case Action::redo:
redo();
return;
case Action::quit:
running_ = false;
return;
case Action::none:
return;
}
state_.selected_day = year_month_day{selected};
state_.visible_month = state_.selected_day.year() / state_.selected_day.month();
}
ExitReason TuiApp::run() {
RawInput raw{input_fd_};
AlternateScreen screen{output_fd_};
ResizeWatcher resize;
const bool terminal_output = ::isatty(output_fd_) != 0;
state_.ansi = terminal_output && std::getenv("NO_COLOR") == nullptr;
running_ = true;
std::string pending;
bool redraw = true;
while (running_) {
if (resize.consume()) {
const auto size = terminal_size(output_fd_);
state_.width = size.columns;
state_.height = size.rows;
redraw = true;
}
if (redraw) {
const auto content = editor_
? editor_->render(state_.width, state_.height, state_.ansi)
: render_month(std::span<const Event>{events_}, state_);
// Cursor control is independent of color styling: NO_COLOR still
// needs in-place redraws rather than appending a new month per key.
const auto frame = terminal_output ? "\x1b[H" + content + "\x1b[J" : content;
if (!write_terminal(output_fd_, frame)) return ExitReason::io_error;
redraw = false;
}
struct pollfd descriptor { input_fd_, POLLIN, 0 };
const auto result = ::poll(&descriptor, 1, pending.empty() ? -1 : 30);
if (result < 0) {
if (errno == EINTR) continue;
return ExitReason::io_error;
}
if (result == 0) {
// Escape is ambiguous with the prefix of a terminal control sequence.
// Once the short sequence window expires, treat it as a key in its own
// right so it can close the appointment reader.
handle_input(pending);
pending.clear();
redraw = true;
continue;
}
if ((descriptor.revents & (POLLERR | POLLNVAL)) != 0) return ExitReason::io_error;
if ((descriptor.revents & POLLHUP) != 0 && (descriptor.revents & POLLIN) == 0) {
return ExitReason::input_closed;
}
if ((descriptor.revents & POLLIN) == 0) continue;
std::array<char, 64> buffer{};
const auto count = ::read(input_fd_, buffer.data(), buffer.size());
if (count == 0) return ExitReason::input_closed;
if (count < 0) {
if (errno == EINTR || errno == EAGAIN) continue;
return ExitReason::io_error;
}
pending.append(buffer.data(), static_cast<std::size_t>(count));
while (const auto length = complete_sequence_length(pending)) {
handle_input(std::string_view{pending}.substr(0, length));
pending.erase(0, length);
redraw = true;
}
}
return ExitReason::quit;
}
} // namespace nocal::tui