#include "nocal/tui/Screen.hpp" #include #include #include #include #include #include #include #include namespace nocal::tui { namespace { using namespace std::chrono; constexpr std::array month_names{ "January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"}; constexpr std::array weekday_long{ "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"}; constexpr std::array 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(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(continuation) >= text.size()) { length = 1; return 0xfffd; } for (int offset = 1; offset <= continuation; ++offset) { const auto byte = static_cast(text[at + static_cast(offset)]); if ((byte & 0xc0) != 0x80) { length = 1; return 0xfffd; } result = (result << 6) | (byte & 0x3f); } length = static_cast(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(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(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::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(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 result{'0', '0', '\0'}; result[0] = static_cast('0' + (value / 10) % 10); result[1] = static_cast('0' + value % 10); return std::string{result.data(), 2}; } std::string iso_date(const year_month_day day) { return std::to_string(static_cast(day.year())) + "-" + two_digits(static_cast(day.month())) + "-" + two_digits(static_cast(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); std::string segment; switch (item.segment) { case ItemSegment::single: break; case ItemSegment::start: segment = "┌ "; break; case ItemSegment::continuation: segment = "│ "; break; case ItemSegment::end: segment = "└ "; break; } if (item.all_day || !item.time_of_day) { return fit_text((segment.empty() ? "• " : segment) + title, width); } const auto total = item.time_of_day->count(); const auto hour = static_cast((total / 60 + 24) % 24); const auto minute = static_cast((total % 60 + 60) % 60); if (width < 8) { return fit_text(title, width); } return fit_text(two_digits(hour) + ":" + two_digits(minute) + " " + segment + 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& 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((total / 60 + 24) % 24); const auto minute = static_cast((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(value.day())) + " " + std::string{month_names[static_cast(value.month()) - 1]} + " " + std::to_string(static_cast(value.year())); } std::vector wrap_text(const std::string_view text, const int requested_width) { const int width = std::max(1, requested_width); std::vector 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 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 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 tiny(static_cast(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(line)]; } return output.str(); } std::vector 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(current - selected_items.begin() + 1); const auto total = std::max(1, selected_items.size()); const int inner = width - 2; std::vector> content; const auto add = [&](std::string text, std::string style = {}) { content.emplace_back(fit_text(text, inner, false), std::move(style)); }; add(focused.recurring ? " RECURRING APPOINTMENT" : " 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)); if (focused.recurring) { add(" Repeats " + (focused.recurrence_summary.empty() ? std::string{"Recurring series"} : printable_text(focused.recurrence_summary))); if (!focused.source_time_zone.empty()) { add(" Source TZ " + printable_text(focused.source_time_zone)); } add(" Series Edit and delete affect every occurrence", "1;33"); } else if (!focused.source_time_zone.empty()) { add(" Source TZ " + printable_text(focused.source_time_zone)); } 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 / search 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(inner), ' '); std::string style; if (line == body_rows - 1) { value = fit_text(footer, inner); style = "7"; } else if (line < static_cast(content.size())) { value = content[static_cast(line)].first; style = content[static_cast(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(focused.recurring ? " Delete entire recurring series “" + title + "”?" : " 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(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(local.tm_mon + 1)} / day{static_cast(local.tm_mday)}, hours{local.tm_hour} + minutes{local.tm_min}, }; } std::string recurrence_summary(const RecurrenceRule& rule) { std::string unit; switch (rule.frequency) { case RecurrenceFrequency::daily: unit = rule.interval == 1 ? "day" : "days"; break; case RecurrenceFrequency::weekly: unit = rule.interval == 1 ? "week" : "weeks"; break; case RecurrenceFrequency::monthly: unit = rule.interval == 1 ? "month" : "months"; break; case RecurrenceFrequency::yearly: unit = rule.interval == 1 ? "year" : "years"; break; } auto result = rule.interval == 1 ? "Every " + unit : "Every " + std::to_string(rule.interval) + " " + unit; if (!rule.by_weekday.empty()) { result += " on "; for (std::size_t index = 0; index < rule.by_weekday.size(); ++index) { if (index != 0) result += ", "; const auto weekday_index = rule.by_weekday[index].iso_encoding() - 1U; if (weekday_index < weekday_short.size()) result += weekday_short[weekday_index]; } } if (!rule.by_month_day.empty()) { result += " on day "; for (std::size_t index = 0; index < rule.by_month_day.size(); ++index) { if (index != 0) result += ", "; result += std::to_string(rule.by_month_day[index]); } } if (!rule.by_month.empty()) { result += " in "; for (std::size_t index = 0; index < rule.by_month.size(); ++index) { if (index != 0) result += ", "; const auto month = rule.by_month[index]; result += month > 0 && month <= month_names.size() ? std::string{month_names[month - 1U]} : std::to_string(month); } } if (rule.count) result += ", " + std::to_string(*rule.count) + " occurrences"; return result; } std::size_t event_source_index(const std::span events, const Event* source) { for (std::size_t index = 0; index < events.size(); ++index) { if (&events[index] == source) return index; } throw std::invalid_argument("occurrence source is outside the event collection"); } std::string source_focus_id(const std::span 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::string search_frame(const ScreenState& state) { const int width = std::max(1, state.width); const int height = std::max(1, state.height); std::vector lines; lines.reserve(static_cast(height)); lines.push_back(styled(centred("SEARCH APPOINTMENTS", width), "1", state.ansi)); if (state.search_prompt) { if (height > 1) lines.push_back(fit_text("/ " + printable_text(state.search_query) + "_", width, false)); if (height > 2) lines.push_back(styled(fit_text( "Search title, description, location, or calendar ID", width), "2", state.ansi)); if (height > 3) lines.push_back(styled(fit_text( "Enter search Backspace edit Esc cancel", width), "2", state.ansi)); } else { const auto count = state.search_results.size(); const auto summary = count == 0 ? "No matches for “" + printable_text(state.search_query) + "”" : std::to_string(count) + (count == 1 ? " match for “" : " matches for “") + printable_text(state.search_query) + "”"; if (height > 1) lines.push_back(styled(fit_text(summary, width), count == 0 ? "1" : "1;36", state.ansi)); const auto row_capacity = static_cast(std::max(0, height - 4)); std::size_t first = 0; if (row_capacity > 0 && state.search_result_index >= row_capacity) { first = state.search_result_index - row_capacity + 1; } const auto last = std::min(count, first + row_capacity); for (std::size_t index = first; index < last; ++index) { const auto& result = state.search_results[index]; std::string when = iso_date(result.day) + " "; if (result.all_day || !result.time_of_day) when += "all day"; else when += clock_time(*result.time_of_day); auto title = result.title.empty() ? std::string{"(untitled)"} : printable_text(result.title); std::string label = (index == state.search_result_index ? "› " : " ") + when + " " + (result.recurring ? "↻ " : "") + title; if (!result.location.empty()) label += " — " + printable_text(result.location); lines.push_back(styled(fit_text(label, width), index == state.search_result_index ? "7;1" : "", state.ansi)); } while (static_cast(lines.size()) < height - 2) { lines.emplace_back(static_cast(width), ' '); } if (height > 2) lines.push_back(styled(fit_text( "Five years either side of the selected date", width), "2", state.ansi)); if (height > 1) lines.push_back(styled(fit_text( "↑/↓ choose Enter jump / new search Esc close", width), "2", state.ansi)); } while (static_cast(lines.size()) < height) { lines.emplace_back(static_cast(width), ' '); } if (static_cast(lines.size()) > height) lines.resize(static_cast(height)); std::ostringstream output; for (int line = 0; line < height; ++line) { if (line != 0) output << '\n'; output << lines[static_cast(line)]; } return output.str(); } std::string compact_frame(std::span 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 lines; const auto title = std::string{month_names[static_cast(state.visible_month.month()) - 1]} + " " + std::to_string(static_cast(state.visible_month.year())); lines.push_back(styled(centred(title, width), "1", state.ansi)); std::array tokens{}; for (int column = 0; column < 7; ++column) { tokens[static_cast(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(date.day())); const auto token = tokens[static_cast(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(lines.size()) < height - 1) { lines.emplace_back(static_cast(width), ' '); } std::string controls; if (!state.notification.empty()) { controls = printable_text(state.notification, true); } else if (focused == nullptr) { controls = "/ search a add u undo Ctrl-R redo ? help n/p month t today q quit"; } else { controls = "Tab appointment Enter open / search e edit d delete u undo Ctrl-R redo"; } if (static_cast(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(lines.size()) > height) lines.resize(static_cast(height)); std::ostringstream output; for (int line = 0; line < height; ++line) { if (line != 0) output << '\n'; output << lines[static_cast(line)]; } return output.str(); } } // namespace std::string occurrence_focus_id(const std::span events, const EventOccurrence& occurrence) { const auto index = event_source_index(events, occurrence.source); auto identity = source_focus_id(events, index); if (occurrence.source->recurrence) { const auto tick = occurrence.start.time_since_epoch().count(); identity += "@occ:" + std::to_string(tick); } return identity; } 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 == "/") return Action::search; if (sequence == "q" || sequence == "Q" || sequence == "\x03") return Action::quit; return Action::none; } std::string render_month(std::span items, const ScreenState& state) { if (state.search_prompt || state.show_search_results) return search_frame(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 widths(7, inner_total / 7); for (int column = 0; column < inner_total % 7; ++column) { ++widths[static_cast(column)]; } const int content_rows = std::max(6, state.height - 10); std::vector row_heights(6, content_rows / 6); for (int row = 0; row < content_rows % 6; ++row) { ++row_heights[static_cast(row)]; } const auto first = sys_days{state.visible_month / day{1}}; const auto grid_start = monday_on_or_before(first); std::map> 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(state.visible_month.month()) - 1; const auto title = "‹ " + std::string{month_names[month_index]} + " " + std::to_string(static_cast(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(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(column)]; std::string cell; std::string style; if (line == 0) { cell = " " + std::to_string(static_cast(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(line - 1); const auto slots = static_cast(row_heights[static_cast(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(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(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 / search 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 / search 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" : "") + " / search 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 events, const ScreenState& state) { std::vector items; items.reserve(events.size() * 2); const auto visible_start = monday_on_or_before(sys_days{state.visible_month / day{1}}); const auto visible_end = visible_start + days{41}; const auto occurrences = occurrences_overlapping( events, local_day_start(year_month_day{visible_start}), local_day_start(year_month_day{visible_end + days{1}})); for (const auto& occurrence : occurrences) { if (occurrence.source == nullptr) continue; const auto& event = *occurrence.source; const auto identity = occurrence_focus_id(events, occurrence); const auto local_start = to_local(occurrence.start); const auto local_finish = to_local(occurrence.end); const auto one_second = duration_cast(seconds{1}); const auto final_instant = occurrence.end > occurrence.start ? occurrence.end - std::min(occurrence.end - occurrence.start, one_second) : occurrence.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; ItemSegment segment = ItemSegment::single; if (event_start != event_last) { if (date == event_start) segment = ItemSegment::start; else if (date == event_last) segment = ItemSegment::end; else segment = ItemSegment::continuation; } items.push_back(CalendarItem{ .id = identity, .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}, .segment = segment, .recurring = event.recurrence.has_value(), .recurrence_summary = event.recurrence ? recurrence_summary(*event.recurrence) : std::string{}, .source_time_zone = event.time_basis == TimeBasis::zoned ? event.time_zone : std::string{}, }); } } return render_month(std::span{items}, state); } } // namespace nocal::tui