feat: initialize Nocal terminal calendar
This commit is contained in:
459
src/tui/EventEditor.cpp
Normal file
459
src/tui/EventEditor.cpp
Normal file
@@ -0,0 +1,459 @@
|
||||
#include "nocal/tui/EventEditor.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <ctime>
|
||||
#include <random>
|
||||
#include <sstream>
|
||||
#include <stdexcept>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
namespace nocal::tui {
|
||||
namespace {
|
||||
|
||||
constexpr std::size_t field_count = static_cast<std::size_t>(EditorField::count);
|
||||
constexpr std::array<std::string_view, field_count> labels{
|
||||
"Title", "Start date", "Start time", "End date", "End time", "All day", "Location", "Notes",
|
||||
};
|
||||
|
||||
std::size_t index_of(const EditorField field) noexcept
|
||||
{
|
||||
return static_cast<std::size_t>(field);
|
||||
}
|
||||
|
||||
std::string make_uid()
|
||||
{
|
||||
static std::atomic<std::uint64_t> sequence{0};
|
||||
static const std::uint64_t seed = [] {
|
||||
std::random_device random;
|
||||
const auto high = static_cast<std::uint64_t>(random()) << 32U;
|
||||
return high ^ static_cast<std::uint64_t>(random());
|
||||
}();
|
||||
const auto now = std::chrono::duration_cast<std::chrono::nanoseconds>(
|
||||
std::chrono::system_clock::now().time_since_epoch())
|
||||
.count();
|
||||
std::ostringstream stream;
|
||||
stream << "nocal-" << std::hex << static_cast<std::uint64_t>(now) << '-'
|
||||
<< (seed ^ sequence.fetch_add(1, std::memory_order_relaxed)) << "@local";
|
||||
return stream.str();
|
||||
}
|
||||
|
||||
std::string two_digits(const unsigned value)
|
||||
{
|
||||
std::array<char, 3> buffer{};
|
||||
std::snprintf(buffer.data(), buffer.size(), "%02u", value);
|
||||
return {buffer.data(), 2};
|
||||
}
|
||||
|
||||
std::pair<unsigned, unsigned> local_hour_minute(const TimePoint point)
|
||||
{
|
||||
const auto time = Clock::to_time_t(point);
|
||||
std::tm value{};
|
||||
#if defined(_WIN32)
|
||||
if (::localtime_s(&value, &time) != 0) {
|
||||
throw std::runtime_error("could not convert event time");
|
||||
}
|
||||
#else
|
||||
if (::localtime_r(&time, &value) == nullptr) {
|
||||
throw std::runtime_error("could not convert event time");
|
||||
}
|
||||
#endif
|
||||
return {static_cast<unsigned>(value.tm_hour), static_cast<unsigned>(value.tm_min)};
|
||||
}
|
||||
|
||||
std::string format_time(const TimePoint point)
|
||||
{
|
||||
const auto [hour, minute] = local_hour_minute(point);
|
||||
return two_digits(hour) + ':' + two_digits(minute);
|
||||
}
|
||||
|
||||
bool parse_time(const std::string_view text, unsigned& hour, unsigned& minute) noexcept
|
||||
{
|
||||
if (text.size() != 5 || text[2] != ':' || text[0] < '0' || text[0] > '9' ||
|
||||
text[1] < '0' || text[1] > '9' || text[3] < '0' || text[3] > '9' ||
|
||||
text[4] < '0' || text[4] > '9') {
|
||||
return false;
|
||||
}
|
||||
hour = static_cast<unsigned>((text[0] - '0') * 10 + text[1] - '0');
|
||||
minute = static_cast<unsigned>((text[3] - '0') * 10 + text[4] - '0');
|
||||
return hour < 24 && minute < 60;
|
||||
}
|
||||
|
||||
void erase_last_codepoint(std::string& text)
|
||||
{
|
||||
if (text.empty()) {
|
||||
return;
|
||||
}
|
||||
auto at = text.size() - 1;
|
||||
while (at > 0 && (static_cast<unsigned char>(text[at]) & 0xc0U) == 0x80U) {
|
||||
--at;
|
||||
}
|
||||
text.erase(at);
|
||||
}
|
||||
|
||||
bool printable_input(const std::string_view key) noexcept
|
||||
{
|
||||
if (key.empty() || key.front() == '\x1b') {
|
||||
return false;
|
||||
}
|
||||
return std::all_of(key.begin(), key.end(), [](const char character) {
|
||||
const auto byte = static_cast<unsigned char>(character);
|
||||
return byte >= 0x20U && byte != 0x7fU;
|
||||
});
|
||||
}
|
||||
|
||||
std::string single_line(std::string_view value)
|
||||
{
|
||||
std::string result{value};
|
||||
std::replace(result.begin(), result.end(), '\n', ' ');
|
||||
std::replace(result.begin(), result.end(), '\r', ' ');
|
||||
return result;
|
||||
}
|
||||
|
||||
struct DecodedCodepoint {
|
||||
std::uint32_t value;
|
||||
std::size_t length;
|
||||
};
|
||||
|
||||
DecodedCodepoint decode_codepoint(const std::string_view text, const std::size_t at) noexcept
|
||||
{
|
||||
const auto first = static_cast<unsigned char>(text[at]);
|
||||
if (first < 0x80U) return {first, 1};
|
||||
std::uint32_t value = 0;
|
||||
std::size_t length = 1;
|
||||
if ((first & 0xe0U) == 0xc0U) {
|
||||
value = first & 0x1fU;
|
||||
length = 2;
|
||||
} else if ((first & 0xf0U) == 0xe0U) {
|
||||
value = first & 0x0fU;
|
||||
length = 3;
|
||||
} else if ((first & 0xf8U) == 0xf0U) {
|
||||
value = first & 0x07U;
|
||||
length = 4;
|
||||
} else {
|
||||
return {0xfffdU, 1};
|
||||
}
|
||||
if (at + length > text.size()) return {0xfffdU, 1};
|
||||
for (std::size_t offset = 1; offset < length; ++offset) {
|
||||
const auto byte = static_cast<unsigned char>(text[at + offset]);
|
||||
if ((byte & 0xc0U) != 0x80U) return {0xfffdU, 1};
|
||||
value = (value << 6U) | (byte & 0x3fU);
|
||||
}
|
||||
return {value, length};
|
||||
}
|
||||
|
||||
int codepoint_width(const std::uint32_t value) noexcept
|
||||
{
|
||||
if (value == 0 || (value >= 0x0300U && value <= 0x036fU) ||
|
||||
(value >= 0x1ab0U && value <= 0x1affU) ||
|
||||
(value >= 0x1dc0U && value <= 0x1dffU) ||
|
||||
(value >= 0x20d0U && value <= 0x20ffU) ||
|
||||
(value >= 0xfe00U && value <= 0xfe0fU) ||
|
||||
(value >= 0xfe20U && value <= 0xfe2fU)) {
|
||||
return 0;
|
||||
}
|
||||
if ((value >= 0x1100U && value <= 0x115fU) || value == 0x2329U || value == 0x232aU ||
|
||||
(value >= 0x2e80U && value <= 0xa4cfU) ||
|
||||
(value >= 0xac00U && value <= 0xd7a3U) ||
|
||||
(value >= 0xf900U && value <= 0xfaffU) ||
|
||||
(value >= 0xfe10U && value <= 0xfe19U) ||
|
||||
(value >= 0xfe30U && value <= 0xfe6fU) ||
|
||||
(value >= 0xff00U && value <= 0xff60U) ||
|
||||
(value >= 0xffe0U && value <= 0xffe6U) ||
|
||||
(value >= 0x2600U && value <= 0x27bfU) ||
|
||||
(value >= 0x1f300U && value <= 0x1faffU) ||
|
||||
(value >= 0x20000U && value <= 0x3fffdU)) {
|
||||
return 2;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
std::string fit(std::string_view text, const int width)
|
||||
{
|
||||
if (width <= 0) return {};
|
||||
std::string result;
|
||||
int used = 0;
|
||||
for (std::size_t at = 0; at < text.size();) {
|
||||
const auto decoded = decode_codepoint(text, at);
|
||||
const auto cell_width = codepoint_width(decoded.value);
|
||||
if (used + cell_width > width) break;
|
||||
result.append(text.substr(at, decoded.length));
|
||||
at += decoded.length;
|
||||
used += cell_width;
|
||||
}
|
||||
result.append(static_cast<std::size_t>(width - used), ' ');
|
||||
return result;
|
||||
}
|
||||
|
||||
std::string style(std::string text, const std::string_view code, const bool ansi)
|
||||
{
|
||||
if (!ansi || text.empty()) return text;
|
||||
return "\x1b[" + std::string{code} + 'm' + std::move(text) + "\x1b[0m";
|
||||
}
|
||||
|
||||
std::string interior_line(std::string content, const int width)
|
||||
{
|
||||
if (width <= 0) return {};
|
||||
if (width == 1) return "│";
|
||||
return "│" + fit(content, width - 2) + "│";
|
||||
}
|
||||
|
||||
std::string styled_interior_line(std::string content, const int width,
|
||||
const std::string_view code, const bool ansi)
|
||||
{
|
||||
if (width <= 0) return {};
|
||||
if (width == 1) return "│";
|
||||
return "│" + style(fit(content, width - 2), code, ansi) + "│";
|
||||
}
|
||||
|
||||
std::string horizontal_line(const int width, const std::string_view left,
|
||||
const std::string_view fill, const std::string_view right)
|
||||
{
|
||||
if (width <= 0) return {};
|
||||
if (width == 1) return std::string{left};
|
||||
std::string result{left};
|
||||
for (int column = 0; column < width - 2; ++column) result += fill;
|
||||
result += right;
|
||||
return result;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
EventEditor::EventEditor(const Date selected_day)
|
||||
{
|
||||
if (!selected_day.ok()) {
|
||||
throw std::invalid_argument("event editor requires a valid selected date");
|
||||
}
|
||||
original_.uid = make_uid();
|
||||
result_ = original_;
|
||||
values_[index_of(EditorField::start_date)] = format_date(selected_day);
|
||||
values_[index_of(EditorField::start_time)] = "09:00";
|
||||
values_[index_of(EditorField::end_date)] = format_date(selected_day);
|
||||
values_[index_of(EditorField::end_time)] = "10:00";
|
||||
}
|
||||
|
||||
EventEditor::EventEditor(const Event& event)
|
||||
: original_{event}, result_{event}, all_day_{event.all_day}, editing_{true}
|
||||
{
|
||||
values_[index_of(EditorField::title)] = event.title;
|
||||
values_[index_of(EditorField::start_date)] = format_date(local_date(event.start));
|
||||
values_[index_of(EditorField::start_time)] = format_time(event.start);
|
||||
values_[index_of(EditorField::end_date)] = format_date(local_date(event.end));
|
||||
values_[index_of(EditorField::end_time)] = format_time(event.end);
|
||||
values_[index_of(EditorField::location)] = event.location;
|
||||
values_[index_of(EditorField::notes)] = event.description;
|
||||
}
|
||||
|
||||
std::string_view EventEditor::field_value(const EditorField field) const noexcept
|
||||
{
|
||||
if (field == EditorField::all_day || field == EditorField::count) return {};
|
||||
return values_[index_of(field)];
|
||||
}
|
||||
|
||||
void EventEditor::move_focus(const int direction) noexcept
|
||||
{
|
||||
const auto current = static_cast<int>(index_of(focused_));
|
||||
const auto count = static_cast<int>(field_count);
|
||||
focused_ = static_cast<EditorField>((current + direction + count) % count);
|
||||
}
|
||||
|
||||
EditorOutcome EventEditor::handle_key(const std::string_view key)
|
||||
{
|
||||
if (key == "\x1b") return EditorOutcome::cancel;
|
||||
if (key == "\x13") return validate() ? EditorOutcome::submit : EditorOutcome::active;
|
||||
if (key == "\t" || key == "\x1b[B" || key == "\x1b[C") {
|
||||
move_focus(1);
|
||||
return EditorOutcome::active;
|
||||
}
|
||||
if (key == "\x1b[Z" || key == "\x1b[A" || key == "\x1b[D") {
|
||||
move_focus(-1);
|
||||
return EditorOutcome::active;
|
||||
}
|
||||
if (key == "\r" || key == "\n") {
|
||||
if (focused_ == EditorField::notes) {
|
||||
values_[index_of(focused_)] += '\n';
|
||||
} else {
|
||||
move_focus(1);
|
||||
}
|
||||
return EditorOutcome::active;
|
||||
}
|
||||
if (focused_ == EditorField::all_day) {
|
||||
if (key == " ") {
|
||||
all_day_ = !all_day_;
|
||||
if (all_day_) {
|
||||
try {
|
||||
const auto start = parse_date(values_[index_of(EditorField::start_date)]);
|
||||
const auto end = parse_date(values_[index_of(EditorField::end_date)]);
|
||||
if (start == end) {
|
||||
values_[index_of(EditorField::end_date)] =
|
||||
format_date(from_sys_days(to_sys_days(start) + std::chrono::days{1}));
|
||||
}
|
||||
} catch (const std::exception&) {
|
||||
// Leave malformed input untouched; submit validation will
|
||||
// focus it and provide the specific correction message.
|
||||
}
|
||||
}
|
||||
}
|
||||
return EditorOutcome::active;
|
||||
}
|
||||
|
||||
auto& value = values_[index_of(focused_)];
|
||||
if (key == "\x7f" || key == "\b") {
|
||||
erase_last_codepoint(value);
|
||||
error_.clear();
|
||||
} else if (printable_input(key)) {
|
||||
value.append(key);
|
||||
error_.clear();
|
||||
}
|
||||
return EditorOutcome::active;
|
||||
}
|
||||
|
||||
bool EventEditor::validate()
|
||||
{
|
||||
const auto fail = [this](const EditorField field, std::string message) {
|
||||
focused_ = field;
|
||||
error_ = std::move(message);
|
||||
return false;
|
||||
};
|
||||
const auto& title = values_[index_of(EditorField::title)];
|
||||
if (title.find_first_not_of(" \t\r\n") == std::string::npos) {
|
||||
return fail(EditorField::title, "Title is required.");
|
||||
}
|
||||
|
||||
Date start_day;
|
||||
Date end_day;
|
||||
try {
|
||||
start_day = parse_date(values_[index_of(EditorField::start_date)]);
|
||||
} catch (const std::exception&) {
|
||||
return fail(EditorField::start_date, "Start date must be a valid YYYY-MM-DD date.");
|
||||
}
|
||||
try {
|
||||
end_day = parse_date(values_[index_of(EditorField::end_date)]);
|
||||
} catch (const std::exception&) {
|
||||
return fail(EditorField::end_date, "End date must be a valid YYYY-MM-DD date.");
|
||||
}
|
||||
|
||||
Event candidate = original_;
|
||||
if (candidate.uid.empty()) candidate.uid = make_uid();
|
||||
candidate.title = title;
|
||||
candidate.location = values_[index_of(EditorField::location)];
|
||||
candidate.description = values_[index_of(EditorField::notes)];
|
||||
candidate.all_day = all_day_;
|
||||
|
||||
try {
|
||||
if (all_day_) {
|
||||
if (to_sys_days(end_day) <= to_sys_days(start_day)) {
|
||||
return fail(EditorField::end_date,
|
||||
"All-day end is exclusive and must be at least the next day.");
|
||||
}
|
||||
candidate.start = local_day_start(start_day);
|
||||
candidate.end = local_day_start(end_day);
|
||||
} else {
|
||||
unsigned start_hour = 0;
|
||||
unsigned start_minute = 0;
|
||||
unsigned end_hour = 0;
|
||||
unsigned end_minute = 0;
|
||||
if (!parse_time(values_[index_of(EditorField::start_time)], start_hour, start_minute)) {
|
||||
return fail(EditorField::start_time, "Start time must be a valid HH:MM time.");
|
||||
}
|
||||
if (!parse_time(values_[index_of(EditorField::end_time)], end_hour, end_minute)) {
|
||||
return fail(EditorField::end_time, "End time must be a valid HH:MM time.");
|
||||
}
|
||||
candidate.start = make_local_time(start_day, start_hour, start_minute);
|
||||
candidate.end = make_local_time(end_day, end_hour, end_minute);
|
||||
if (candidate.end <= candidate.start) {
|
||||
return fail(EditorField::end_time, "End must be after start.");
|
||||
}
|
||||
}
|
||||
} catch (const std::exception&) {
|
||||
return fail(EditorField::start_date,
|
||||
"That local date and time cannot be represented in this time zone.");
|
||||
}
|
||||
|
||||
result_ = std::move(candidate);
|
||||
error_.clear();
|
||||
return true;
|
||||
}
|
||||
|
||||
std::string EventEditor::render(const int requested_width, const int requested_height,
|
||||
const bool ansi) const
|
||||
{
|
||||
const int width = std::max(0, requested_width);
|
||||
const int height = std::max(0, requested_height);
|
||||
if (height == 0) return {};
|
||||
|
||||
const auto top = horizontal_line(width, "┌", "─", "┐");
|
||||
const auto rule = horizontal_line(width, "├", "─", "┤");
|
||||
const auto bottom = horizontal_line(width, "└", "─", "┘");
|
||||
std::vector<std::string> lines;
|
||||
lines.reserve(16);
|
||||
lines.push_back(top);
|
||||
lines.push_back(styled_interior_line(editing_ ? " NOCAL · EDIT APPOINTMENT"
|
||||
: " NOCAL · NEW APPOINTMENT",
|
||||
width, "1", ansi));
|
||||
lines.push_back(styled_interior_line(" Tab/↓ next Shift-Tab/↑ back Space toggle",
|
||||
width, "2", ansi));
|
||||
lines.push_back(rule);
|
||||
|
||||
for (std::size_t index = 0; index < field_count; ++index) {
|
||||
const auto field = static_cast<EditorField>(index);
|
||||
const bool selected = focused_ == field;
|
||||
std::string value;
|
||||
if (field == EditorField::all_day) {
|
||||
value = all_day_ ? "[x] spans whole days (end date is exclusive)"
|
||||
: "[ ] timed appointment";
|
||||
} else if (all_day_ && (field == EditorField::start_time || field == EditorField::end_time)) {
|
||||
value = "— not used for all-day appointments —";
|
||||
} else {
|
||||
value = single_line(values_[index]);
|
||||
if (selected) value += "_";
|
||||
}
|
||||
std::string row = selected ? " › " : " ";
|
||||
row += fit(labels[index], 11);
|
||||
row += " ";
|
||||
row += value;
|
||||
lines.push_back(styled_interior_line(std::move(row), width, selected ? "7" : "", ansi));
|
||||
}
|
||||
|
||||
lines.push_back(rule);
|
||||
lines.push_back(interior_line(error_.empty() ? " Ready" : " ! " + error_, width));
|
||||
lines.push_back(styled_interior_line(" Ctrl-S save Esc cancel", width, "1", ansi));
|
||||
lines.push_back(bottom);
|
||||
|
||||
// Keep the active field visible on short terminals by selecting a window
|
||||
// from the complete form, while retaining a top and bottom frame.
|
||||
if (static_cast<int>(lines.size()) > height) {
|
||||
std::vector<std::string> compact;
|
||||
compact.reserve(static_cast<std::size_t>(height));
|
||||
compact.push_back(top);
|
||||
if (height > 2) {
|
||||
const int available = height - 2;
|
||||
const int selected_line = 4 + static_cast<int>(index_of(focused_));
|
||||
const int maximum_start = static_cast<int>(lines.size()) - 1 - available;
|
||||
const int start = std::clamp(selected_line - available / 2, 1,
|
||||
std::max(1, maximum_start));
|
||||
for (int offset = 0; offset < available; ++offset) {
|
||||
compact.push_back(lines[static_cast<std::size_t>(start + offset)]);
|
||||
}
|
||||
}
|
||||
if (height > 1) compact.push_back(bottom);
|
||||
lines = std::move(compact);
|
||||
}
|
||||
while (static_cast<int>(lines.size()) < height) {
|
||||
lines.insert(lines.end() - 1, interior_line({}, width));
|
||||
}
|
||||
|
||||
std::string frame;
|
||||
for (int row = 0; row < height; ++row) {
|
||||
if (row != 0) frame += '\n';
|
||||
frame += lines[static_cast<std::size_t>(row)];
|
||||
}
|
||||
return frame;
|
||||
}
|
||||
|
||||
} // namespace nocal::tui
|
||||
732
src/tui/Screen.cpp
Normal file
732
src/tui/Screen.cpp
Normal 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
112
src/tui/Terminal.cpp
Normal 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
573
src/tui/TuiApp.cpp
Normal 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
|
||||
Reference in New Issue
Block a user