diff --git a/taicpp/src/tui/TuiApp.cpp b/taicpp/src/tui/TuiApp.cpp new file mode 100644 index 0000000..1857ad6 --- /dev/null +++ b/taicpp/src/tui/TuiApp.cpp @@ -0,0 +1,1118 @@ +#include "TuiApp.h" +#include "TuiTerminal.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +namespace detz::tui { + +const char* const TUI_MOUSE_ENABLE = "\x1b[?1000h\x1b[?1006h"; +const char* const TUI_MOUSE_DISABLE = "\x1b[?1006l\x1b[?1000l"; +const char* const TUI_ALT_ENTER = "\x1b[?1049h"; +const char* const TUI_ALT_LEAVE = "\x1b[?1049l"; +const char* const TUI_KITTY_ENABLE = "\x1b[>10u"; +const char* const TUI_KITTY_DISABLE = "\x1b[(std::chrono::system_clock::now().time_since_epoch()).count(); +} + +namespace { +std::string str_fmt(const char* fmt, long a, long b) { + char buf[64]; + std::snprintf(buf, sizeof buf, fmt, a, b); + return buf; +} +} // namespace + +TuiApp::TuiApp(PanelP root_in, int fps_in) : root(std::move(root_in)), fps(std::max(1, fps_in)) { + if (!root) { + TuiPanelOpts o; + o.flow.emplace(); + o.style = mk_style(TuiColor(), "black"); + root = std::make_shared(o); + } + const char* v = std::getenv("TAI_TUI_VERIFY"); + verify = v && *v; + if (::pipe(wake_pipe_) == 0) { + for (int i = 0; i < 2; i++) { + fcntl(wake_pipe_[i], F_SETFL, fcntl(wake_pipe_[i], F_GETFL) | O_NONBLOCK); + fcntl(wake_pipe_[i], F_SETFD, FD_CLOEXEC); + } + } +} + +TuiApp::~TuiApp() { + if (active_ == this) active_ = nullptr; + for (int i = 0; i < 2; i++) if (wake_pipe_[i] >= 0) ::close(wake_pipe_[i]); +} + +// ---- structure --------------------------------------------------------------- + +PanelP TuiApp::mount(PanelP new_root) { + if (new_root) root = new_root; + root->bind_app(this); + focused = nullptr; + focus_keep_.reset(); + layout_full_ = true; + stack_.clear(); + layout(); + auto_focus(); + dirty_paint(); + return root; +} + +void TuiApp::dirty_layout(bool paint) { + { + std::lock_guard g(lock_); + need_layout_ = true; + need_paint_ = true; + layout_full_ = true; + if (paint) drop_all_ = true; + } + wake(); +} + +void TuiApp::relayout(TuiPanel* panel, bool inner, bool restack_flag, bool only) { + PanelP keep = panel->weak_from_this().lock(); + { + std::lock_guard g(lock_); + need_layout_ = true; + need_paint_ = true; + if (layout_starts_.size() >= LAYOUT_STARTS || !keep) { + layout_full_ = true; + } else { + layout_starts_.emplace_back(keep, only ? ModeSelf : inner ? ModeInner : ModeOwn); + panel->mark_pending(); + } + if (restack_flag) { + restack_ = true; + tree_version_++; + } + } + wake(); +} + +void TuiApp::run_on_loop(std::function call) { + if (!loop_active_ || std::this_thread::get_id() == loop_thread_) { + call(); + return; + } + { + std::lock_guard g(queue_lock_); + queue_.push_back(std::move(call)); + } + signal_wake(); +} + +void TuiApp::tree_changed() { + std::lock_guard g(lock_); + tree_version_++; +} + +void TuiApp::restack() { + { + std::lock_guard g(lock_); + need_layout_ = true; + need_paint_ = true; + restack_ = true; + } + wake(); +} + +void TuiApp::dirty_paint() { + { + std::lock_guard g(lock_); + need_paint_ = true; + drop_all_ = true; + } + wake(); +} + +void TuiApp::damage(TuiPanel* panel) { + PanelP keep = panel->weak_from_this().lock(); + { + std::lock_guard g(lock_); + if (keep && std::find(damage_.begin(), damage_.end(), keep) == damage_.end()) damage_.push_back(keep); + need_paint_ = true; + if (panel->w().is("auto") || panel->h().is("auto")) { + if (keep && std::find(watch_.begin(), watch_.end(), keep) == watch_.end()) watch_.push_back(keep); + need_layout_ = true; + } + } + wake(); +} + +void TuiApp::wake() { + if (!loop_active_) return; + wake_flag_ = true; + if (std::this_thread::get_id() != loop_thread_) signal_wake(); +} + +void TuiApp::signal_wake() { + if (wake_pipe_[1] >= 0) { + char b = 1; + (void)!::write(wake_pipe_[1], &b, 1); + } +} + +std::shared_ptr TuiApp::set_theme(const std::string& name) { + TuiThemes::set(name); + compositor.invalidate(); + dirty_paint(); + return TuiThemes::current(); +} + +TuiApp::TimerP TuiApp::after(double delay, std::function cb) { + auto h = std::make_shared(Timer{now() + std::max(0.0, delay), false, 0.0, std::move(cb), ++timer_seq_}); + timers.push_back(h); + wake(); + return h; +} + +TuiApp::TimerP TuiApp::every(double interval, std::function cb) { + auto h = std::make_shared(Timer{now() + std::max(0.0, interval), true, interval, std::move(cb), ++timer_seq_}); + timers.push_back(h); + wake(); + return h; +} + +void TuiApp::cancel_timer(const TimerP& handle) { + timers.erase(std::remove(timers.begin(), timers.end(), handle), timers.end()); +} + +void TuiApp::fire_timers(double t) { + std::vector copy = timers; + for (auto& handle : copy) { + if (t < handle->deadline) continue; + try { + handle->callback(); + } catch (const std::exception&) { + } + if (!handle->has_interval) { + timers.erase(std::remove(timers.begin(), timers.end(), handle), timers.end()); + } else { + handle->deadline = t + handle->interval; + } + } +} + +// ---- focus ------------------------------------------------------------------- + +void TuiApp::assign_focus(TuiPanel* panel) { + focused = panel; + focus_keep_ = panel ? panel->weak_from_this().lock() : nullptr; +} + +TuiPanel* TuiApp::top_modal() { + TuiPanel* best = nullptr; + for (TuiPanel* node : root->walk()) { + if (node->modal() && node->is_shown() && node->enabled()) { + if (!best || std::make_pair(node->effective_z(), node->seq) > std::make_pair(best->effective_z(), best->seq)) best = node; + } + } + return best; +} + +std::vector TuiApp::focusables(TuiPanel* scope) { + TuiPanel* modal = top_modal(); + TuiPanel* base = modal ? modal : (scope ? scope : root.get()); + std::vector found; + for (TuiPanel* node : base->walk()) if (node->focusable() && node->enabled() && node->is_shown()) found.push_back(node); + std::stable_sort(found.begin(), found.end(), [](TuiPanel* a, TuiPanel* b) { + return std::make_tuple(a->ay, a->ax, a->seq) < std::make_tuple(b->ay, b->ax, b->seq); + }); + return found; +} + +void TuiApp::set_focus(TuiPanel* panel) { + if (panel && (!panel->focusable() || !panel->enabled() || !panel->is_shown())) return; + TuiPanel* modal = top_modal(); + if (modal && panel) { + TuiPanel* node = panel; + bool inside = false; + while (node) { + if (node == modal) { inside = true; break; } + node = node->parent; + } + if (!inside) return; + } + if (focused == panel) return; + TuiPanel* old = focused; + PanelP old_keep = focus_keep_; + assign_focus(panel); + if (old) { + TuiBlur ev; + ev.panel = old; + old->emit(ev); + old->touch(); + } + if (panel) { + TuiFocus ev; + ev.panel = panel; + panel->emit(ev); + panel->touch(); + } + need_paint_ = true; +} + +void TuiApp::focus_first(TuiPanel* scope) { + auto found = focusables(scope); + if (!found.empty()) set_focus(found[0]); +} + +void TuiApp::auto_focus() { + auto found = focusables(); + for (TuiPanel* node : found) { + if (!dynamic_cast(node)) { + set_focus(node); + return; + } + } + if (!found.empty()) set_focus(found[0]); +} + +void TuiApp::remember_focus() { focus_memory_.push_back(focus_keep_); } + +void TuiApp::restore_focus() { + while (!focus_memory_.empty()) { + PanelP prev = focus_memory_.back(); + focus_memory_.pop_back(); + if (prev && prev->focusable() && prev->enabled() && prev->is_shown() && prev->root() == root.get()) { + set_focus(prev.get()); + if (focused == prev.get()) return; + } + } + auto_focus(); +} + +void TuiApp::focus_next() { + auto found = focusables(); + if (found.empty()) return; + auto it = std::find(found.begin(), found.end(), focused); + if (it != found.end()) set_focus(found[static_cast((it - found.begin() + 1) % static_cast(found.size()))]); + else set_focus(found[0]); +} + +void TuiApp::focus_prev() { + auto found = focusables(); + if (found.empty()) return; + auto it = std::find(found.begin(), found.end(), focused); + if (it != found.end()) { + long n = static_cast(found.size()); + set_focus(found[static_cast(((it - found.begin() - 1) % n + n) % n)]); + } else { + set_focus(found.back()); + } +} + +void TuiApp::ensure_focus() { + TuiPanel* node = focused; + if (!node || node->root() != root.get() || !node->is_shown() || !node->enabled() || !node->focusable()) { + assign_focus(nullptr); + auto_focus(); + } +} + +void TuiApp::close_panel(TuiPanel* panel) { + PanelP keep = panel->weak_from_this().lock(); + TuiClose ev; + ev.panel = panel; + panel->emit(ev); + if (panel->parent) panel->parent->remove(panel); + TuiPanel* node = focused; + if (node && (node == panel || node->root() != root.get() || !node->is_shown())) { + assign_focus(nullptr); + restore_focus(); + } +} + +// ---- layout + paint ------------------------------------------------------------ + +void TuiApp::layout() { + bool full, restack_now; + std::vector> starts; + std::vector watch, touched; + { + std::lock_guard g(lock_); + need_layout_ = false; + full = layout_full_; layout_full_ = false; + restack_now = restack_; restack_ = false; + starts.swap(layout_starts_); + watch.swap(watch_); + touched = damage_; + } + for (auto& panel : watch) { + auto seen = panel->measure_seen_; + auto size = panel->measure(); + if ((!seen || *seen != size) && panel->root() == root.get()) { + bool outer = !seen || (panel->w().is("auto") && size.first != seen->first) || (panel->h().is("auto") && size.second != seen->second); + starts.emplace_back(panel, outer ? ModeInner : ModeSelf); + } + } + bool everything = full || stack_.empty(); + std::vector roots; + if (everything) { + roots = {root}; + } else { + bool ok = true; + roots = layout_roots(starts, &ok); + if (!ok) { + everything = true; + roots = {root}; + } + } + std::unordered_map> memo; + TuiPanel::measure_memo = &memo; + std::vector arranged; + if (!everything) { + for (auto& item : starts) item.first->mark_pending(); + for (auto& panel : touched) panel->mark_pending(); + TuiPanel::stale = true; + TuiPanel::arranged = &arranged; + } + try { + for (auto& node : roots) { + if (everything) { + node->arrange(0, 0, cols, rows, TuiRect{{0, 0, cols, rows}}); + } else { + auto& a = *node->arrange_; + node->arrange(a[0], a[1], a[2], a[3], TuiRect{{a[4], a[5], a[6], a[7]}}); + } + } + } catch (...) { + TuiPanel::measure_memo = nullptr; + TuiPanel::stale = false; + TuiPanel::arranged = nullptr; + throw; + } + TuiPanel::measure_memo = nullptr; + TuiPanel::stale = false; + TuiPanel::arranged = nullptr; + layouts[everything ? "full" : "partial"]++; + if (verify && !everything) verify_layout(); + if (everything || restack_now || geometry_changed_membership(arranged)) restack_diff(); + else geometry_diff(arranged); + std::vector raw; + for (auto& p : stack_) raw.push_back(p.get()); + hitmap.rebuild(raw); +} + +std::vector TuiApp::layout_roots(const std::vector>& starts, bool* ok) { + std::vector found; + for (auto& item : starts) { + TuiPanel* panel = item.first.get(); + Mode mode = item.second; + if (panel->root() != root.get()) continue; + TuiPanel* node = mode == ModeOwn ? panel->parent : panel; + if (!node) { *ok = false; return {}; } + while (mode != ModeSelf && node->content_sized()) node = node->parent; + while (!node->arrange_ && node->parent) node = node->parent; + if (!node->arrange_) { *ok = false; return {}; } + if (node->is_shown()) found.push_back(node->weak_from_this().lock()); + } + std::vector roots; + for (auto& node : found) { + TuiPanel* above = node->parent; + while (above && std::none_of(found.begin(), found.end(), [&](const PanelP& f) { return f.get() == above; })) above = above->parent; + if (!above && std::find(roots.begin(), roots.end(), node) == roots.end()) roots.push_back(node); + } + *ok = true; + return roots; +} + +bool TuiApp::geometry_changed_membership(const std::vector& arranged) { + for (TuiPanel* node : arranged) { + bool listed = node->geom_.has_value(); + if (listed != (node->aw > 0 && node->ah > 0)) return true; + } + return false; +} + +void TuiApp::geometry_diff(const std::vector& arranged) { + for (TuiPanel* node : arranged) { + std::array geom{{node->ax, node->ay, node->aw, node->ah, node->clip[0], node->clip[1], node->clip[2], node->clip[3]}}; + auto was = node->geom_; + layouts["arranged"]++; + if (!was || *was == geom) continue; + geom_rects_.push_back(TuiRect{{(*was)[4], (*was)[5], (*was)[6], (*was)[7]}}); + geom_rects_.push_back(TuiRect{{geom[4], geom[5], geom[6], geom[7]}}); + if ((*was)[2] != geom[2] || (*was)[3] != geom[3]) node->cells_.reset(); + node->geom_ = geom; + } +} + +void TuiApp::restack_diff() { + std::vector old = stack_; + std::vector flat = compositor.flatten(root.get()); + std::vector stack; + for (TuiPanel* p : flat) stack.push_back(p->weak_from_this().lock()); + tree_version_++; + if (old.empty()) { + geom_full_ = true; + } else if (stack != old) { + std::set now_set, before_set; + for (auto& p : stack) now_set.insert(p.get()); + for (auto& p : old) before_set.insert(p.get()); + std::vector a, b; + for (auto& p : old) if (now_set.count(p.get())) a.push_back(p.get()); + for (auto& p : stack) if (before_set.count(p.get())) b.push_back(p.get()); + if (a != b) { + geom_full_ = true; + } else { + for (auto& p : old) { + if (!now_set.count(p.get())) { + if (p->geom_) geom_rects_.push_back(TuiRect{{(*p->geom_)[4], (*p->geom_)[5], (*p->geom_)[6], (*p->geom_)[7]}}); + p->geom_.reset(); + } + } + } + } + for (auto& node : stack) { + std::array geom{{node->ax, node->ay, node->aw, node->ah, node->clip[0], node->clip[1], node->clip[2], node->clip[3]}}; + auto was = node->geom_; + if (!was) { + geom_rects_.push_back(TuiRect{{geom[4], geom[5], geom[6], geom[7]}}); + } else if (*was != geom) { + geom_rects_.push_back(TuiRect{{(*was)[4], (*was)[5], (*was)[6], (*was)[7]}}); + geom_rects_.push_back(TuiRect{{geom[4], geom[5], geom[6], geom[7]}}); + if ((*was)[2] != geom[2] || (*was)[3] != geom[3]) node->cells_.reset(); + } + node->geom_ = geom; + } + layouts["arranged"] += static_cast(stack.size()); + stack_ = std::move(stack); +} + +void TuiApp::verify_layout() { + struct Snap { TuiPanel* node; int ax, ay, aw, ah; TuiRect clip; int sx, sy; }; + auto snapshot = [&] { + std::vector out; + for (TuiPanel* node : root->walk()) { + if (node->arrange_) out.push_back(Snap{node, node->ax, node->ay, node->aw, node->ah, node->clip, node->scroll_x(), node->scroll_y()}); + } + return out; + }; + auto before = snapshot(); + std::unordered_map> memo; + TuiPanel::measure_memo = &memo; + try { + root->arrange(0, 0, cols, rows, TuiRect{{0, 0, cols, rows}}); + } catch (...) { + TuiPanel::measure_memo = nullptr; + throw; + } + TuiPanel::measure_memo = nullptr; + auto after = snapshot(); + for (auto& b : before) { + for (auto& a : after) { + if (a.node != b.node) continue; + if (a.ax != b.ax || a.ay != b.ay || a.aw != b.aw || a.ah != b.ah || a.clip != b.clip || a.sx != b.sx || a.sy != b.sy) { + throw TuiError("partial layout diverged from full layout for id=" + b.node->id); + } + } + } +} + +std::optional> TuiApp::damage_rects() { + bool drop; + std::vector touched; + { + std::lock_guard g(lock_); + need_paint_ = false; + drop = drop_all_; + touched.swap(damage_); + drop_all_ = false; + } + std::vector rects; + rects.swap(geom_rects_); + bool full = drop || geom_full_; + geom_full_ = false; + shifts_.clear(); + if (drop) { + for (TuiPanel* node : root->walk()) { + node->cells_.reset(); + node->shift_ = 0; + } + } + if (full) { + for (auto& panel : touched) panel->shift_ = 0; + return std::nullopt; + } + std::set shown; + for (auto& p : stack_) shown.insert(p.get()); + for (auto& panel : touched) { + if (shown.count(panel.get())) { + rects.push_back(panel->clip); + if (panel->shift_) { + TuiRect cr = panel->content_rect(); + shifts_.push_back(TuiShift{cr[0], cr[1], cr[2], cr[3], panel->shift_}); + } + } + panel->shift_ = 0; + } + if (rects.size() > static_cast(TUI_DAMAGE_RECTS)) { + shifts_.clear(); + return std::nullopt; + } + return rects; +} + +std::string TuiApp::frame_bytes() { + if (need_layout_) layout(); + bool fresh = !compositor.last; + auto rects = damage_rects(); + std::vector raw; + for (auto& p : stack_) raw.push_back(p.get()); + std::string body = compositor.refresh(raw, rows, cols, rects, rects ? shifts_ : std::vector{}); + std::string cursor = cursor_bytes(); + if (verify) verify_frame(); + if (body.empty() && !fresh && last_cursor_ && cursor == *last_cursor_) return ""; + last_cursor_ = cursor; + return std::string(TUI_SYNC_START) + "\x1b[?25l" + body + "\x1b[0m" + cursor + TUI_SYNC_END; +} + +void TuiApp::verify_frame() { + TuiCellRows want = compositor.compose_reference(root.get(), rows, cols); + const TuiCellRows& have = *compositor.last; + verified++; + for (size_t y = 0; y < want.size() && y < have.size(); y++) { + if (want[y] != have[y]) { + size_t x = 0; + while (x < want[y].size() && want[y][x] == have[y][x]) x++; + throw TuiError("lazy frame diverged from reference at " + str_fmt("x=%ld y=%ld", static_cast(x), static_cast(y))); + } + } +} + +std::string TuiApp::cursor_bytes() { + auto* node = dynamic_cast(focused); + if (node && !node->exit_status && node->screen.cursor_visible && node->is_shown()) { + int edge = node->border() ? 1 : 0; + int x = node->ax + edge + node->padding()[3] + static_cast(node->screen.cx); + int y = node->ay + edge + node->padding()[0] + static_cast(node->screen.cy); + if (node->clip[0] <= x && x < node->clip[2] && node->clip[1] <= y && y < node->clip[3]) return TuiCompositor::place_cursor(x, y, true); + } + return TuiCompositor::place_cursor(0, 0, false); +} + +std::string TuiApp::render_text() { + if (need_layout_) layout(); + return TuiCompositor::render_text(compositor.compose(root.get(), rows, cols)); +} + +// ---- events --------------------------------------------------------------------- + +void TuiApp::dispatch(TuiEvent& event) { + const char* n = event.name(); + if (!std::strcmp(n, "key")) { + auto& key = static_cast(event); + if (key.key == "ctrl+c") { + quit(); + return; + } + if (on_double_tap && double_tap.hit(key.key, now())) { + if (on_double_tap(key)) return; + } + if (key.key == "tab") { focus_next(); return; } + if (key.key == "backtab") { focus_prev(); return; } + if (focused) focused->emit(event); + else root->emit(event); + return; + } + if (!std::strcmp(n, "mode_reply")) { + auto& m = static_cast(event); + if (m.mode == TUI_DECLRMM && (m.state == 1 || m.state == 2)) compositor.margins = true; + return; + } + if (!std::strcmp(n, "click")) { dispatch_click(static_cast(event)); return; } + if (!std::strcmp(n, "scroll")) { dispatch_scroll(static_cast(event)); return; } + if (!std::strcmp(n, "paste")) { + if (focused) focused->emit(event); + else root->emit(event); + return; + } + root->emit(event); +} + +bool TuiApp::modal_blocks(TuiPanel* panel) { + TuiPanel* modal = top_modal(); + if (!modal || !panel) return modal != nullptr && panel == nullptr; + TuiPanel* node = panel; + while (node) { + if (node == modal) return false; + node = node->parent; + } + return true; +} + +void TuiApp::dispatch_click(TuiClick& event) { + TuiPanel* hit = hitmap.at(event.x, event.y); + if (!hit || modal_blocks(hit)) return; + PanelP keep = hit->weak_from_this().lock(); + if (event.pressed) { + TuiPanel* node = hit; + while (node && !(node->focusable() && node->enabled() && node->is_shown())) node = node->parent; + if (node) set_focus(node); + } + hit->emit(event); +} + +void TuiApp::dispatch_scroll(TuiScroll& event) { + TuiPanel* hit = hitmap.at(event.x, event.y); + if (!hit || modal_blocks(hit)) return; + PanelP keep = hit->weak_from_this().lock(); + hit->emit(event); +} + +// ---- animations -------------------------------------------------------------------- + +namespace { +double ease(const std::string& name, double progress) { + double t = std::max(0.0, std::min(1.0, progress)); + if (name == "linear") return t; + if (name == "in_quad") return t * t; + if (name == "out_quad") return 1.0 - (1.0 - t) * (1.0 - t); + if (name == "in_cubic") return t * t * t; + if (name == "out_cubic") return 1.0 - (1.0 - t) * (1.0 - t) * (1.0 - t); + if (name == "in_out_quad") return t < 0.5 ? 2.0 * t * t : 1.0 - std::pow(-2.0 * t + 2.0, 2) / 2.0; + return t < 0.5 ? 4.0 * t * t * t : 1.0 - std::pow(-2.0 * t + 2.0, 3) / 2.0; +} +} // namespace + +double TuiApp::ease_value(const std::string& name, double progress) { return ease(name, progress); } + +void TuiApp::tick_animations(double t) { + if (animations.empty()) return; + auto copy = animations; + for (auto& anim : copy) { + TuiPanel* panel = anim->panel; + if (!anim->started) { + anim->started = true; + anim->started_at = t; + anim->from = panel->anim_start(anim->prop); + } + double elapsed = t - anim->started_at - anim->delay; + if (elapsed < 0) continue; + double progress = std::min(1.0, elapsed / anim->duration); + double value = anim->from + (anim->to - anim->from) * ease(anim->easing, progress); + bool geometry = anim->prop == "x" || anim->prop == "y" || anim->prop == "w" || anim->prop == "h"; + if (geometry) value = std::nearbyint(value); + panel->set_numeric(anim->prop, value); + if (progress >= 1.0) { + animations.erase(std::remove(animations.begin(), animations.end(), anim), animations.end()); + if (anim->on_done) { + try { + anim->on_done(panel); + } catch (const std::exception&) { + } + } + } + } +} + +std::vector TuiApp::tickers() { + if (tickers_version_ != tree_version_) { + tickers_.clear(); + for (TuiPanel* node : root->walk()) if (node->ticks()) tickers_.push_back(node); + tickers_version_ = tree_version_; + } + return tickers_; +} + +void TuiApp::tick_widgets(double dt, double t) { + for (TuiPanel* node : tickers()) { + if (!node->ticks()) continue; + TuiTick ev; + ev.panel = node; + ev.dt = dt; + ev.now = t; + try { + auto it = node->handlers_.find("tick"); + if (it != node->handlers_.end()) it->second(ev); + else node->on_tick(ev); + } catch (const std::exception&) { + } + } +} + +// ---- terminal ------------------------------------------------------------------------- + +std::pair TuiApp::winsize() { + int r = 24, c = 80; + struct winsize ws{}; + int fd = stdout_fd >= 0 ? stdout_fd : STDOUT_FILENO; + if (ioctl(fd, TIOCGWINSZ, &ws) == 0) { + r = ws.ws_row; + c = ws.ws_col; + } + return {std::max(TUI_MIN_ROWS, r), std::max(TUI_MIN_COLS, c)}; +} + +std::vector TuiApp::terminals() { + if (terms_version_ != tree_version_) { + terms_.clear(); + for (TuiPanel* node : root->walk()) if (auto* t = dynamic_cast(node)) terms_.push_back(t); + terms_version_ = tree_version_; + } + return terms_; +} + +void TuiApp::on_winch(int) { + if (active_) { + active_->resize_pending_ = true; + active_->wake_flag_ = true; + active_->signal_wake(); + } +} + +void TuiApp::on_chld(int signum) { + if (!active_) return; + active_->wake_flag_ = true; + active_->signal_wake(); + if (active_->saved_termios_ && active_->saved_termios_->have_chld) { + auto& old = active_->saved_termios_->old_chld; + if (!(old.sa_flags & SA_SIGINFO) && old.sa_handler != SIG_DFL && old.sa_handler != SIG_IGN && old.sa_handler) old.sa_handler(signum); + } +} + +void TuiApp::write_all(int fd, const std::string& data) { + size_t off = 0; + while (off < data.size()) { + ssize_t n = ::write(fd, data.data() + off, data.size() - off); + if (n < 0) { + if (errno == EINTR) continue; + if (errno == EAGAIN) { + struct pollfd p{fd, POLLOUT, 0}; + ::poll(&p, 1, 100); + continue; + } + throw std::system_error(errno, std::generic_category(), "write"); + } + off += static_cast(n); + } +} + +void TuiApp::setup() { + if (!isatty(STDIN_FILENO) || !isatty(STDOUT_FILENO)) throw TuiError("tui needs an interactive terminal"); + auto wh = winsize(); + rows = wh.first; + cols = wh.second; + stdout_fd = STDOUT_FILENO; + saved_termios_ = std::make_unique(); + int fd = STDIN_FILENO; + tcgetattr(fd, &saved_termios_->tio); + saved_termios_valid_ = true; + // tty.setraw(fd) + struct termios raw = saved_termios_->tio; + raw.c_iflag &= ~(BRKINT | ICRNL | INPCK | ISTRIP | IXON); + raw.c_oflag &= ~OPOST; + raw.c_cflag &= ~(CSIZE | PARENB); + raw.c_cflag |= CS8; + raw.c_lflag &= ~(ECHO | ICANON | IEXTEN | ISIG); + raw.c_cc[VMIN] = 1; + raw.c_cc[VTIME] = 0; + tcsetattr(fd, TCSAFLUSH, &raw); + active_ = this; + struct sigaction sa{}; + sa.sa_handler = &TuiApp::on_winch; + sigemptyset(&sa.sa_mask); + saved_termios_->have_winch = sigaction(SIGWINCH, &sa, &saved_termios_->old_winch) == 0; + struct sigaction sc{}; + sc.sa_handler = &TuiApp::on_chld; + sigemptyset(&sc.sa_mask); + sc.sa_flags = SA_NOCLDSTOP; + saved_termios_->have_chld = sigaction(SIGCHLD, &sc, &saved_termios_->old_chld) == 0; + std::string seq = std::string(TUI_ALT_ENTER) + TUI_MOUSE_ENABLE + TUI_KITTY_ENABLE + TUI_PASTE_ENABLE + "\x1b[?25l" + "\x1b[?" + std::to_string(TUI_DECLRMM) + "$p"; + write_all(STDOUT_FILENO, seq); +} + +void TuiApp::teardown() { + try { + write_all(stdout_fd >= 0 ? stdout_fd : STDOUT_FILENO, + std::string(TUI_PASTE_DISABLE) + TUI_KITTY_DISABLE + TUI_MOUSE_DISABLE + TUI_ALT_LEAVE + "\x1b[?25h"); + } catch (const std::exception&) { + } + if (saved_termios_) { + if (saved_termios_->have_winch) sigaction(SIGWINCH, &saved_termios_->old_winch, nullptr); + if (saved_termios_->have_chld) sigaction(SIGCHLD, &saved_termios_->old_chld, nullptr); + if (saved_termios_valid_) tcsetattr(STDIN_FILENO, TCSADRAIN, &saved_termios_->tio); + } + for (TuiTerminal* term : terminals()) { + try { + term->terminate(); + } catch (const std::exception&) { + } + } + if (active_ == this) active_ = nullptr; +} + +void TuiApp::quit() { + running_ = false; + wake(); +} + +int TuiApp::run() { + setup(); + try { + main_loop(); + } catch (...) { + teardown(); + throw; + } + teardown(); + if (!failure_.empty()) { + std::string failure; + failure.swap(failure_); + throw std::runtime_error(failure); + } + return 0; +} + +void TuiApp::offload(std::function work, std::function done) { + if (!loop_active_) { + TuiValue result; + std::string error; + try { + result = work(); + } catch (const std::exception& e) { + error = e.what(); + if (error.empty()) error = "error"; + } + done(result, error); + return; + } + auto finish = [this](TuiValue result, std::string error, std::function cb) { + try { + cb(result, error); + } catch (...) { + dirty_paint(); + throw; + } + dirty_paint(); + }; + std::thread worker([this, work = std::move(work), done = std::move(done), finish]() mutable { + TuiValue result; + std::string error; + try { + result = work(); + } catch (const std::exception& e) { + error = e.what(); + if (error.empty()) error = "error"; + } + run_on_loop([finish, result, error, done] { finish(result, error, done); }); + }); + worker.detach(); +} + +void TuiApp::drain_queue() { + std::vector> batch; + { + std::lock_guard g(queue_lock_); + batch.swap(queue_); + } + for (auto& fn : batch) { + try { + fn(); + } catch (const std::exception& e) { + failure_ = e.what(); + running_ = false; + wake(); + } + } +} + +void TuiApp::on_stdin(int fd) { + char buf[65536]; + ssize_t n = ::read(fd, buf, sizeof buf); + if (n < 0 && (errno == EAGAIN || errno == EINTR)) return; + if (n <= 0) { + stdin_open_ = false; + running_ = false; + wake(); + return; + } + input_seen_ = true; + for (auto& event : input.feed(std::string(buf, static_cast(n)))) dispatch(*event); + if (now() - last_frame_ >= TUI_INPUT_PAINT_GAP) paint_frame(); + if (!input.buf.empty() || need_paint_ || need_layout_) wake(); +} + +bool TuiApp::paint_frame() { + if (!(need_paint_ || need_layout_)) return true; + try { + std::string data = frame_bytes(); + if (!data.empty()) write_all(stdout_fd, data); + } catch (const std::system_error&) { + running_ = false; + return false; + } + last_frame_ = now(); + return true; +} + +void TuiApp::on_term_readable(int fd, TuiTerminal* term) { + if (!term->feed_output()) { + eof_.insert(fd); + readers_.erase(fd); + } + wake(); +} + +void TuiApp::sync_terminals() { + std::map want_r, want_w; + auto terms = terminals(); + for (TuiTerminal* term : terms) { + term->poll_exit(); + for (int fd : term->fds()) if (!eof_.count(fd)) want_r[fd] = term; + if (term->fd >= 0 && !term->pending.empty() && !term->exit_status) want_w[term->fd] = term; + } + std::set live; + for (TuiTerminal* term : terms) for (int fd : term->fds()) live.insert(fd); + for (auto it = eof_.begin(); it != eof_.end();) it = live.count(*it) ? std::next(it) : eof_.erase(it); + for (auto it = readers_.begin(); it != readers_.end();) it = want_r.count(it->first) ? std::next(it) : readers_.erase(it); + for (auto& kv : want_r) readers_[kv.first] = kv.second; + for (auto it = writers_.begin(); it != writers_.end();) it = want_w.count(it->first) ? std::next(it) : writers_.erase(it); + for (auto& kv : want_w) writers_[kv.first] = kv.second; +} + +void TuiApp::pump(double timeout, bool stop_on_wake) { + double deadline = timeout < 0 ? std::numeric_limits::infinity() : now() + timeout; + while (true) { + drain_queue(); + if (stop_on_wake && wake_flag_) return; + std::vector fds; + fds.push_back({wake_pipe_[0], POLLIN, 0}); + if (stdin_open_) fds.push_back({stdin_fd >= 0 ? stdin_fd : STDIN_FILENO, POLLIN, 0}); + std::vector> rd(readers_.begin(), readers_.end()); + std::vector> wr(writers_.begin(), writers_.end()); + for (auto& kv : rd) fds.push_back({kv.first, POLLIN, 0}); + for (auto& kv : wr) fds.push_back({kv.first, POLLOUT, 0}); + double remaining = deadline - now(); + int ms; + if (remaining <= 0) ms = 0; + else if (std::isinf(remaining)) ms = -1; + else ms = static_cast(std::ceil(remaining * 1000.0)); + int r = ::poll(fds.data(), fds.size(), ms); + if (r > 0) { + size_t idx = 0; + if (fds[idx].revents & POLLIN) { + char drain[256]; + while (::read(wake_pipe_[0], drain, sizeof drain) > 0) {} + } + idx++; + try { + if (stdin_open_) { + if (fds[idx].revents & (POLLIN | POLLHUP | POLLERR)) on_stdin(fds[idx].fd); + idx++; + } + for (size_t k = 0; k < rd.size(); k++, idx++) { + if (fds[idx].revents & (POLLIN | POLLHUP | POLLERR)) on_term_readable(rd[k].first, rd[k].second); + } + for (size_t k = 0; k < wr.size(); k++, idx++) { + if (fds[idx].revents & (POLLOUT | POLLERR)) { + wr[k].second->drain_pending(); + wake(); + } + } + } catch (const std::exception& e) { + failure_ = e.what(); + running_ = false; + wake(); + } + } + if (!running_ && !stop_on_wake) return; + if (timeout >= 0 && now() >= deadline) return; + if (stop_on_wake && wake_flag_) return; + } +} + +double TuiApp::idle_delay(double frame, double started, bool* has_delay) { + double t = now(); + *has_delay = true; + if (need_paint_ || need_layout_ || resize_pending_) return std::max(0.0, last_frame_ + frame - t); + if (!animations.empty() || !tickers().empty() || !input.buf.empty()) return std::max(0.0, frame - (t - started)); + if (timers.empty()) { + *has_delay = false; + return 0.0; + } + double m = std::numeric_limits::infinity(); + for (auto& tm : timers) m = std::min(m, tm->deadline); + return std::max(0.0, m - t); +} + +void TuiApp::main_loop() { + loop_thread_ = std::this_thread::get_id(); + wake_flag_ = false; + loop_active_ = true; + stdin_open_ = true; + dirty_layout(); + running_ = true; + double frame = 1.0 / fps; + double last = now(); + try { + while (running_) { + wake_flag_ = false; + iterations++; + double started = now(); + fire_timers(started); + sync_terminals(); + if (resize_pending_) { + resize_pending_ = false; + auto wh = winsize(); + rows = wh.first; + cols = wh.second; + compositor.invalidate(); + dirty_layout(); + TuiResize ev; + ev.panel = root.get(); + ev.rows = rows; + ev.cols = cols; + root->emit(ev); + } + if (!input_seen_ && !input.buf.empty()) { + for (auto& event : input.flush()) dispatch(*event); + } + input_seen_ = false; + double tick_now = now(); + tick_animations(tick_now); + if (!tickers().empty()) tick_widgets(tick_now - last, tick_now); + last = tick_now; + ensure_focus(); + double wait = last_frame_ + frame - now(); + if ((need_paint_ || need_layout_) && wait > 0) pump(wait, false); + if (!running_) break; + if (!paint_frame()) break; + bool has_delay = false; + double delay = idle_delay(frame, started, &has_delay); + if (has_delay && delay <= 0) pump(0, false); + else pump(has_delay ? delay : -1.0, true); + } + } catch (...) { + loop_active_ = false; + throw; + } + loop_active_ = false; +} + +} // namespace detz::tui diff --git a/taicpp/src/tui/TuiApp.h b/taicpp/src/tui/TuiApp.h new file mode 100644 index 0000000..f38ecc5 --- /dev/null +++ b/taicpp/src/tui/TuiApp.h @@ -0,0 +1,208 @@ +#pragma once +// TuiApp: owns the screen (tai.py TuiApp): terminal setup, tick loop, focus, +// timers, routing. One poll() loop replaces the asyncio loop: stdin and +// sub-terminal ptys are readers (and writers while input is queued) that +// dispatch as bytes arrive, a frame clock advances timers and animations, +// relayouts on demand, composites and writes one frame. Dirty flags skip idle +// frames entirely. Blocking work goes through offload(): it runs on its own +// thread and calls back on the loop. Ctrl+C always quits (terminal state +// restores on the way out). +#include "TuiCompositor.h" +#include "TuiDoubleTap.h" +#include "TuiHitMap.h" +#include "TuiInput.h" +#include "TuiPanel.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +namespace detz::tui { + +class TuiTerminal; + +constexpr int TUI_FPS = 30; +constexpr double TUI_INPUT_PAINT_GAP = 0.008; +constexpr int TUI_MIN_ROWS = 8; +constexpr int TUI_MIN_COLS = 20; + +extern const char* const TUI_MOUSE_ENABLE; +extern const char* const TUI_MOUSE_DISABLE; +extern const char* const TUI_ALT_ENTER; +extern const char* const TUI_ALT_LEAVE; +extern const char* const TUI_KITTY_ENABLE; +extern const char* const TUI_KITTY_DISABLE; +extern const char* const TUI_PASTE_ENABLE; +extern const char* const TUI_PASTE_DISABLE; + +class TuiError : public std::runtime_error { +public: + explicit TuiError(const std::string& what) : std::runtime_error(what) {} +}; + +class TuiApp { +public: + struct Timer { + double deadline; + bool has_interval; + double interval; + std::function callback; + long seq; + }; + using TimerP = std::shared_ptr; + + explicit TuiApp(PanelP root = nullptr, int fps = TUI_FPS); + ~TuiApp(); + + PanelP root; + int fps; + int rows{24}, cols{80}; + TuiPanel* focused{nullptr}; // read-only for callers; written through assign_focus + TuiDoubleTap double_tap; + std::function on_double_tap; + TuiCompositor compositor; + TuiHitMap hitmap; + TuiInput input; + std::vector> animations; + std::vector timers; + std::map layouts{{"full", 0}, {"partial", 0}, {"arranged", 0}}; + bool verify{false}; + long verified{0}; + long iterations{0}; + bool running_loop() const { return loop_active_; } + bool loop_active() const { return loop_active_; } + + // structure + PanelP mount(PanelP new_root = nullptr); + void dirty_layout(bool paint = true); + static constexpr size_t LAYOUT_STARTS = 64; + void relayout(TuiPanel* panel, bool inner = false, bool restack = false, bool only = false); + void run_on_loop(std::function call); + void tree_changed(); + void restack(); + void dirty_paint(); + void damage(TuiPanel* panel); + void wake(); + std::shared_ptr set_theme(const std::string& name); + TimerP after(double delay, std::function callback); + TimerP every(double interval, std::function callback); + void cancel_timer(const TimerP& handle); + + // focus + TuiPanel* top_modal(); + std::vector focusables(TuiPanel* scope = nullptr); + void set_focus(TuiPanel* panel); + void focus_first(TuiPanel* scope = nullptr); + void auto_focus(); + void assign_focus(TuiPanel* panel); + void remember_focus(); + void focus_next(); + void focus_prev(); + void ensure_focus(); + void close_panel(TuiPanel* panel); + + // layout + paint + void layout(); + std::string frame_bytes(); + std::string render_text(); + const std::vector& stack() const { return stack_; } + + // events + void dispatch(TuiEvent& event); + void tick_animations(double now); + static double ease_value(const std::string& name, double progress); // tui_ease + std::vector tickers(); + void tick_widgets(double dt, double now); + + // terminal + std::pair winsize(); + std::vector terminals(); + void quit(); + int run(); + void offload(std::function work, std::function done); + static void write_all(int fd, const std::string& data); + int stdin_fd{-1}, stdout_fd{-1}; + + // test hooks + bool need_layout_flag() const { return need_layout_; } + bool need_paint_flag() const { return need_paint_; } + void fire_timers(double now); + static double now(); + +private: + std::vector focus_memory_; + long timer_seq_{0}; + bool need_layout_{true}, need_paint_{true}; + std::recursive_mutex lock_; + bool drop_all_{true}; + std::vector damage_; + std::vector stack_; + bool layout_full_{true}; + enum Mode { ModeOwn, ModeInner, ModeSelf }; + std::vector> layout_starts_; + std::vector watch_; + std::vector shifts_; + bool restack_{false}; + std::vector geom_rects_; + bool geom_full_{true}; + long tree_version_{0}; + std::optional last_cursor_; + std::atomic running_{false}; + std::atomic resize_pending_{false}; + std::atomic loop_active_{false}; + bool input_seen_{false}; + double last_frame_{0.0}; + std::atomic wake_flag_{false}; + std::thread::id loop_thread_; + std::vector tickers_; + long tickers_version_{-1}; + std::vector terms_; + long terms_version_{-1}; + std::map readers_; + std::map writers_; + std::set eof_; + std::mutex queue_lock_; + std::vector> queue_; + std::vector workers_; + std::string failure_; + bool saved_termios_valid_{false}; + bool stdin_open_{false}; + PanelP focus_keep_; + struct SavedTermios; + std::unique_ptr saved_termios_; + int wake_pipe_[2]{-1, -1}; + + std::vector layout_roots(const std::vector>& starts, bool* ok); + bool geometry_changed_membership(const std::vector& arranged); + void geometry_diff(const std::vector& arranged); + void restack_diff(); + void verify_layout(); + std::optional> damage_rects(); + void verify_frame(); + std::string cursor_bytes(); + bool modal_blocks(TuiPanel* panel); + void dispatch_click(TuiClick& event); + void dispatch_scroll(TuiScroll& event); + void restore_focus(); + void setup(); + void teardown(); + void main_loop(); + void on_stdin(int fd); + bool paint_frame(); + void on_term_readable(int fd, TuiTerminal* term); + void sync_terminals(); + double idle_delay(double frame, double started, bool* has_delay); + void pump(double timeout, bool stop_on_wake); // timeout < 0: no limit + void drain_queue(); + void signal_wake(); + static void on_winch(int); + static void on_chld(int); + static TuiApp* active_; +}; + +} // namespace detz::tui diff --git a/taicpp/src/tui/TuiAutocomplete.cpp b/taicpp/src/tui/TuiAutocomplete.cpp new file mode 100644 index 0000000..6614673 --- /dev/null +++ b/taicpp/src/tui/TuiAutocomplete.cpp @@ -0,0 +1,20 @@ +#include "TuiAutocomplete.h" +namespace detz::tui { + +TuiAutocomplete::TuiAutocomplete(TuiPanelOpts opts) + : TuiOptions({}, 0, false, nullptr, [&] { + if (!opts.focusable) opts.focusable = false; + if (!opts.border) opts.border = true; + if (!opts.layer) opts.layer = "dialog"; + if (!opts.z) opts.z = 5; + return opts; + }()) {} + +void TuiAutocomplete::set_items(const std::vector& items) { + options = items; + set_selected(0); + set_scroll_y(0); + if (app) touch(); +} + +} // namespace detz::tui diff --git a/taicpp/src/tui/TuiAutocomplete.h b/taicpp/src/tui/TuiAutocomplete.h new file mode 100644 index 0000000..14efb10 --- /dev/null +++ b/taicpp/src/tui/TuiAutocomplete.h @@ -0,0 +1,15 @@ +#pragma once +// TuiAutocomplete: non-focusable completion popup owned by a TuiTextBox (tai.py +// TuiAutocomplete). The box keeps focus and drives the list: typing filters, +// arrows move, Enter accepts via the Select event, Esc dismisses. Tab is never +// used: it always moves focus by framework rule. +#include "TuiOptions.h" +namespace detz::tui { + +class TuiAutocomplete : public TuiOptions { +public: + explicit TuiAutocomplete(TuiPanelOpts opts = {}); + void set_items(const std::vector& items); +}; + +} // namespace detz::tui diff --git a/taicpp/src/tui/TuiButton.cpp b/taicpp/src/tui/TuiButton.cpp new file mode 100644 index 0000000..2f28763 --- /dev/null +++ b/taicpp/src/tui/TuiButton.cpp @@ -0,0 +1,57 @@ +#include "TuiButton.h" +#include "TuiApp.h" +namespace detz::tui { + +TuiButton::TuiButton(UStr label, TuiHandler on_press, bool brackets, TuiPanelOpts opts) + : TuiPanel([&] { + if (!opts.focusable) opts.focusable = true; + if (!opts.border) opts.border = true; + return opts; + }()), + label_(std::move(label)), brackets_(brackets) { + if (on_press) set_handler("press", std::move(on_press)); +} + +void TuiButton::set_label(const UStr& v) { bool same = label_ == v; label_ = v; if (app && !same) touch(); } +void TuiButton::set_brackets(bool v) { bool same = brackets_ == v; brackets_ = v; if (app && !same) touch(); } + +std::pair TuiButton::measure() { return {static_cast(label_.size()) + 4, 1}; } + +SpanRows TuiButton::content_rows() { + int width = content_w(), height = content_h(); + UStr text = (brackets_ ? U"[ " + label_ + U" ]" : U" " + label_ + U" "); + UStr label = TuiStr::head(TuiStr::center(text, width), width); + StyleP st = style(); + if (!enabled()) st = st->merged(TuiStyle(TuiColor(), TuiColor(), false, true)); + else if (app && app->focused == this) st = st->merged(TuiStyle(TuiColor(), TuiColor(), true, false, false, true)); + SpanRows rows; + for (int pos = 0; pos < height; pos++) { + if (height == 1 || pos == height / 2) rows.push_back(SpanRow{TuiSpan(label, st)}); + else rows.push_back(SpanRow{TuiSpan(UStr(static_cast(width), U' '), style())}); + } + return rows; +} + +void TuiButton::on_key(TuiKey& event) { + if (!enabled()) return; + if (event.key == "enter" || event.key == "space") { + event.stop(); + press(); + } +} + +void TuiButton::on_click(TuiClick& event) { + if (!enabled() || !event.pressed) return; + event.stop(); + focus(); + press(); +} + +void TuiButton::press() { + TuiPress ev; + ev.panel = this; + emit(ev); + if (app) touch(); +} + +} // namespace detz::tui diff --git a/taicpp/src/tui/TuiButton.h b/taicpp/src/tui/TuiButton.h new file mode 100644 index 0000000..9c3692b --- /dev/null +++ b/taicpp/src/tui/TuiButton.h @@ -0,0 +1,24 @@ +#pragma once +// TuiButton: clickable button; Enter/Space/click press it (tai.py TuiButton). +// on_press is the Press-event handler attribute and receives the event. +#include "TuiPanel.h" +namespace detz::tui { + +class TuiButton : public TuiPanel { +public: + explicit TuiButton(UStr label = UStr(), TuiHandler on_press = nullptr, bool brackets = true, TuiPanelOpts opts = {}); + const UStr& label() const { return label_; } + bool brackets() const { return brackets_; } + void set_label(const UStr& v); + void set_brackets(bool v); + std::pair measure() override; + SpanRows content_rows() override; + void on_key(TuiKey& event) override; + void on_click(TuiClick& event) override; + void press(); +private: + UStr label_; + bool brackets_; +}; + +} // namespace detz::tui diff --git a/taicpp/src/tui/TuiCell.cpp b/taicpp/src/tui/TuiCell.cpp new file mode 100644 index 0000000..b72cc56 --- /dev/null +++ b/taicpp/src/tui/TuiCell.cpp @@ -0,0 +1,48 @@ +#include "TuiCell.h" +#include +#include +#include +namespace detz::tui { + +namespace { +struct KeyHash { + size_t operator()(const TuiStyleKey& k) const { + auto mix = [](size_t h, size_t v) { return h ^ (v + 0x9e3779b97f4a7c15ULL + (h << 6) + (h >> 2)); }; + auto color = [&](size_t h, const TuiColor& c) { + h = mix(h, static_cast(c.kind)); + h = mix(h, static_cast(c.n)); + h = mix(h, std::hash()(c.s)); + for (int v : c.c) h = mix(h, static_cast(v)); + return h; + }; + size_t h = 0; + h = color(h, k.fg); + h = color(h, k.bg); + h = mix(h, (k.bold ? 1u : 0u) | (k.dim ? 2u : 0u) | (k.underline ? 4u : 0u) | (k.reverse ? 8u : 0u) | (k.strike ? 16u : 0u)); + return h; + } +}; +std::mutex& lock() { static std::mutex m; return m; } +std::unordered_map& table() { static std::unordered_map t; return t; } +std::deque& keys() { static std::deque k; return k; } +} // namespace + +int TuiStyleKeys::intern(const TuiStyleKey& key) { + std::lock_guard guard(lock()); + auto it = table().find(key); + if (it != table().end()) return it->second; + int id = static_cast(keys().size()); + keys().push_back(key); + table().emplace(key, id); + return id; +} +const TuiStyleKey& TuiStyleKeys::get(int id) { + std::lock_guard guard(lock()); + return keys().at(static_cast(id)); +} +int TuiStyleKeys::blank_key() { + static const int id = intern(TuiStyleKey()); + return id; +} + +} // namespace detz::tui diff --git a/taicpp/src/tui/TuiCell.h b/taicpp/src/tui/TuiCell.h new file mode 100644 index 0000000..1b6be70 --- /dev/null +++ b/taicpp/src/tui/TuiCell.h @@ -0,0 +1,28 @@ +#pragma once +// TuiCell and TuiStyleKeys: the compositor's cell space. A cell is a char plus +// an interned style key id, so equal cells compare by two integers (python: +// interned (char, key) tuples whose identity short-circuits frame diffs). +#include "TuiStyle.h" +#include +#include +namespace detz::tui { + +struct TuiCell { + char32_t ch{U' '}; + int key{0}; + bool operator==(const TuiCell& o) const { return ch == o.ch && key == o.key; } + bool operator!=(const TuiCell& o) const { return !(*this == o); } +}; +using TuiCellRow = std::vector; +using TuiCellRows = std::vector; + +class TuiStyleKeys { +public: + static int intern(const TuiStyleKey& key); // stable id per distinct key + static const TuiStyleKey& get(int id); + static int blank_key(); // TUI_BLANK_KEY: (None, None, F, F, F, F, F) + static TuiCell blank() { return TuiCell{U' ', blank_key()}; } + static TuiCell never() { return TuiCell{0, -1}; } // python (None, None): never shown +}; + +} // namespace detz::tui diff --git a/taicpp/src/tui/TuiChatList.cpp b/taicpp/src/tui/TuiChatList.cpp new file mode 100644 index 0000000..ea0f828 --- /dev/null +++ b/taicpp/src/tui/TuiChatList.cpp @@ -0,0 +1,96 @@ +#include "TuiChatList.h" +#include "TuiApp.h" +#include +namespace detz::tui { + +TuiChatList::TuiChatList(const std::vector& options, TuiPanelOpts opts) : TuiOptions(options, 0, false, nullptr, std::move(opts)) {} + +const std::vector& TuiChatList::avatar_roles() { + static const std::vector r = {"accent", "accent2", "accent3", "success", "warning", "info"}; + return r; +} +std::string TuiChatList::avatar_role(const UStr& name) { + long sum = 0; + for (char32_t c : name) sum += static_cast(c); + return avatar_roles()[static_cast(sum % static_cast(avatar_roles().size()))]; +} + +std::pair TuiChatList::measure() { + auto wh = TuiOptions::measure(); + return {wh.first, std::max(1, 2 * static_cast(options.size()))}; +} + +void TuiChatList::follow_selection(int height) { + int top = static_cast(selected()) * 2; + if (top < scroll_y()) set_scroll_y(top); + else if (top + 1 >= scroll_y() + std::max(1, height)) set_scroll_y(top + 2 - std::max(1, height)); +} + +SpanRows TuiChatList::content_rows() { + int width = content_w(), height = content_h(); + follow_selection(height); + bool focused = app && app->focused == this; + SpanRows rows; + for (int pos = 0; pos < height; pos++) { + long index = scroll_y() + pos; + long chat = index >= 0 ? index / 2 : -((-index + 1) / 2); // python divmod floor + long sub = index - chat * 2; + if (0 <= chat && chat < static_cast(options.size())) { + const UStr& label = options[static_cast(chat)].label; + const TuiValue& value = options[static_cast(chat)].value; + auto mit = row_meta.find(value.str()); + TuiValue meta = mit != row_meta.end() ? mit->second : TuiValue::dict(); + if (!meta.truthy()) meta = TuiValue::dict(); + bool active = chat == selected() && enabled(); + StyleP base = active ? style()->merged(TuiStyle(TuiColor(), TuiColor("role:selection"))) : style(); + std::string tint = "role:" + avatar_role(label); + TuiSpan bar(active ? UStr(U"▌") : UStr(U" "), base->merged(TuiStyle(TuiColor("role:accent")))); + if (sub == 0) { + UStr initial = TuiStr::upper(TuiStr::head(label, 1)); + if (initial.empty()) initial = U"?"; + TuiSpan chip(U" " + initial + U" ", mk_style("role:on_accent", tint, true)); + TuiSpan name(U" " + TuiStr::head(label, std::max(0, width - 6)), base->merged(TuiStyle(TuiColor(), TuiColor(), active || focused))); + const TuiValue& busy = meta.get("busy"); + SpanRow badge; + if (busy.truthy()) { + badge.push_back(TuiSpan(U" " + (busy.is_str() ? TuiStr::u(busy.as_str()) : UStr(U"●")), + base->merged(TuiStyle(TuiColor("role:warning"), TuiColor(), true)))); + } + long unread = meta.get("unread").truthy() ? meta.get("unread").as_int() : 0; + if (unread) { + badge.push_back(TuiSpan(U" " + TuiStr::u(std::to_string(std::min(unread, 99L))) + U" ", mk_style("role:on_accent", "role:accent", true))); + } + SpanRow row{bar, chip, name}; + row.insert(row.end(), badge.begin(), badge.end()); + rows.push_back(TuiSpans::pad(row, width, base)); + } else { + UStr snippet = TuiStr::u(meta.get("snippet").truthy() ? meta.get("snippet").str() : ""); + UStr draft = TuiStr::u(meta.get("draft").truthy() ? meta.get("draft").str() : ""); + TuiSpan lead(U" ", base); + if (!draft.empty()) { + TuiSpan head(U"Draft: ", base->merged(TuiStyle(TuiColor("role:error")))); + TuiSpan tail(TuiStr::head(draft, std::max(0, width - 12)), base->merged(TuiStyle(TuiColor("role:muted")))); + rows.push_back(TuiSpans::pad(SpanRow{bar, lead, head, tail}, width, base)); + } else { + TuiSpan tail(TuiStr::head(snippet, std::max(0, width - 6)), base->merged(TuiStyle(TuiColor("role:muted")))); + rows.push_back(TuiSpans::pad(SpanRow{bar, lead, tail}, width, base)); + } + } + } else { + rows.push_back(SpanRow{TuiSpan(UStr(static_cast(width), U' '), style())}); + } + } + return rows; +} + +void TuiChatList::on_click(TuiClick& event) { + if (!enabled() || !event.pressed) return; + event.stop(); + focus(); + int pad_top = padding()[0] + (border() ? 1 : 0); + long index = scroll_y() + (event.y - ay - pad_top); + long chat = index >= 0 ? index / 2 : -((-index + 1) / 2); + if (0 <= chat && chat < static_cast(options.size())) choose(chat, true); +} + +} // namespace detz::tui diff --git a/taicpp/src/tui/TuiChatList.h b/taicpp/src/tui/TuiChatList.h new file mode 100644 index 0000000..ff71b25 --- /dev/null +++ b/taicpp/src/tui/TuiChatList.h @@ -0,0 +1,22 @@ +#pragma once +// TuiChatList: two-line chat rows, avatar chip + name / snippet (tai.py +// TuiChatList) plus tui_avatar_role. Options carry (label, value) as usual; +// row_meta maps value -> {"snippet": str, "draft": str, "busy": bool or a +// spinner glyph, "unread": int}. Selection moves per chat while each chat +// paints two terminal rows. +#include "TuiOptions.h" +namespace detz::tui { + +class TuiChatList : public TuiOptions { +public: + explicit TuiChatList(const std::vector& options = {}, TuiPanelOpts opts = {}); + static const std::vector& avatar_roles(); // TUI_AVATAR_ROLES + static std::string avatar_role(const UStr& name); // stable accent role per name + std::map row_meta; // keyed by the option value's string form + std::pair measure() override; + void follow_selection(int height) override; + SpanRows content_rows() override; + void on_click(TuiClick& event) override; +}; + +} // namespace detz::tui diff --git a/taicpp/src/tui/TuiChatLog.cpp b/taicpp/src/tui/TuiChatLog.cpp new file mode 100644 index 0000000..1827f5d --- /dev/null +++ b/taicpp/src/tui/TuiChatLog.cpp @@ -0,0 +1,63 @@ +#include "TuiChatLog.h" +#include +namespace detz::tui { + +std::optional> TuiChatLog::bar_geometry() { + int width = content_w(), height = content_h(); + int total = static_cast(lines.size()); + if (total <= height || height < 3 || width < 2) return std::nullopt; + int thumb = std::max(1, height * height / total); + int span = std::max(1, total - height); + double ratio = std::min(1.0, scroll_y() / static_cast(span)); + return std::make_pair(static_cast((height - thumb) * ratio), thumb); +} + +TuiCellRows TuiChatLog::paint_cells(TuiCompositor& compositor) { + int edge = border() ? 1 : 0; + if (edge || aw < chrome_w() + 2) return TuiPanel::paint_cells(compositor); + TuiCellRows rows = log_cells(compositor); + auto geometry = bar_geometry(); + if (!geometry) return rows; + int top = geometry->first, thumb = geometry->second; + int height = content_h(); + int pad_top = padding()[0], pad_right = padding()[1]; + TuiCell blank = compositor.cell(U' ', style()->key()); + TuiCell track = compositor.cell(U'│', TuiStyle(TuiColor("role:border"), style()->bg()).key()); + TuiCell grip = compositor.cell(U'┃', TuiStyle(TuiColor("role:accent"), style()->bg(), true).key()); + int at = aw - pad_right - 1; + TuiCellRow tail(static_cast(pad_right), blank); + for (int local = 0; local < height; local++) { + TuiCellRow& row = rows[static_cast(pad_top + local)]; + TuiCellRow out(row.begin(), row.begin() + std::max(0, std::min(at, static_cast(row.size())))); + out.push_back(top <= local && local < top + thumb ? grip : track); + out.insert(out.end(), tail.begin(), tail.end()); + row = std::move(out); + } + return rows; +} + +SpanRows TuiChatLog::paint() { + SpanRows rows = TuiLog::paint(); + auto geometry = bar_geometry(); + if (!geometry) return rows; + int top = geometry->first, thumb = geometry->second; + int height = content_h(); + int edge = border() ? 1 : 0; + StyleP track = mk_style("role:border", style()->bg()); + StyleP grip = mk_style("role:accent", style()->bg(), true); + SpanRows out; + for (size_t pos = 0; pos < rows.size(); pos++) { + int local = static_cast(pos) - edge - padding()[0]; + SpanRow spans = rows[pos]; + if (0 <= local && local < height) { + bool on = top <= local && local < top + thumb; + spans = TuiSpans::slice(spans, 0, aw - edge - padding()[1] - 1, style()); + spans.push_back(TuiSpan(on ? UStr(U"┃") : UStr(U"│"), on ? grip : track)); + spans = TuiSpans::pad(spans, aw - edge, style()); + } + out.push_back(std::move(spans)); + } + return out; +} + +} // namespace detz::tui diff --git a/taicpp/src/tui/TuiChatLog.h b/taicpp/src/tui/TuiChatLog.h new file mode 100644 index 0000000..eeb0396 --- /dev/null +++ b/taicpp/src/tui/TuiChatLog.h @@ -0,0 +1,16 @@ +#pragma once +// TuiChatLog: transcript view with a slim scrollbar in the last column, drawn +// only when there is something to scroll (tai.py TuiChatLog). +#include "TuiLog.h" +namespace detz::tui { + +class TuiChatLog : public TuiLog { +public: + explicit TuiChatLog(TuiPanelOpts opts = {}) : TuiLog(std::move(opts)) {} + // (thumb top row, thumb height) of the scrollbar, or none when there is nothing to scroll + std::optional> bar_geometry(); + TuiCellRows paint_cells(TuiCompositor& compositor) override; + SpanRows paint() override; +}; + +} // namespace detz::tui diff --git a/taicpp/src/tui/TuiComplete.cpp b/taicpp/src/tui/TuiComplete.cpp new file mode 100644 index 0000000..dc1edbb --- /dev/null +++ b/taicpp/src/tui/TuiComplete.cpp @@ -0,0 +1,111 @@ +#include "TuiComplete.h" +#include "TuiStr.h" +#include +namespace detz::tui { + +std::map TuiComplete::help_; +bool TuiComplete::help_ready_ = false; + +TuiComplete::Sources& TuiComplete::sources() { + static Sources s; + return s; +} + +void TuiComplete::reset_command_help() { + help_.clear(); + help_ready_ = false; +} + +const std::map& TuiComplete::command_help() { + if (!help_ready_) { + help_ready_ = true; + UStr text = TuiStr::u(sources().help_text); + for (auto& line : TuiStr::splitlines(text)) { + size_t i = 0; + while (i < line.size() && TuiStr::isspace(line[i])) i++; + if (i >= line.size() || line[i] != U'/') continue; + i++; + size_t name_start = i; + while (i < line.size() && !TuiStr::isspace(line[i])) i++; + if (i == name_start || i >= line.size()) continue; // \S+ then at least one \s + UStr name = line.substr(name_start, i - name_start); + while (i < line.size() && TuiStr::isspace(line[i])) i++; + UStr rest = TuiStr::strip(line.substr(i)); + // re.split(r"\s{2,}", rest)[-1] + size_t last_start = 0; + size_t k = 0; + while (k < rest.size()) { + if (TuiStr::isspace(rest[k])) { + size_t j = k; + while (j < rest.size() && TuiStr::isspace(rest[j])) j++; + if (j - k >= 2) last_start = j; + k = j; + } else { + k++; + } + } + UStr desc = TuiStr::strip(rest.substr(last_start)); + help_.emplace(TuiStr::s(name), TuiStr::s(desc)); // setdefault + } + } + return help_; +} + +std::optional TuiComplete::word_match(const UStr& text) { + // re.search(r"(?:^|\s)([/@])([A-Za-z0-9_./-]*)$", text): the leftmost trigger wins + for (size_t p = 0; p < text.size(); p++) { + char32_t c = text[p]; + if (c != U'/' && c != U'@') continue; + if (p > 0 && !TuiStr::isspace(text[p - 1])) continue; + bool ok = true; + for (size_t e = p + 1; e < text.size() && ok; e++) { + char32_t w = text[e]; + ok = (w >= 'A' && w <= 'Z') || (w >= 'a' && w <= 'z') || (w >= '0' && w <= '9') || w == '_' || w == '.' || w == '/' || w == '-'; + } + if (ok) return WordMatch{c, text.substr(p + 1), p}; + } + return std::nullopt; +} + +std::vector TuiComplete::mention(const UStr& prefix_u, const std::string& directory, const std::vector& bots, + const std::vector& models_in) { + std::vector found; + std::string prefix = TuiStr::s(prefix_u); + std::string low = TuiStr::s(TuiStr::lower(prefix_u)); + auto starts = [&](const std::string& value) { return TuiStr::s(TuiStr::lower(TuiStr::u(value))).compare(0, low.size(), low) == 0; }; + for (auto& name : bots) { + if (starts(name)) found.push_back({TuiStr::u("@" + name + " [bot]"), TuiValue(name)}); + } + Sources& src = sources(); + for (auto& label : src.provider_labels) { + if (starts(label)) found.push_back({TuiStr::u("@" + label + " [provider]"), TuiValue(label)}); + } + if (src.mention_auto.compare(0, low.size(), low) == 0) found.push_back({TuiStr::u("@" + src.mention_auto + " [reset pins]"), TuiValue(src.mention_auto)}); + std::vector models = models_in; + if (models.empty() && src.default_models) models = src.default_models(); + for (auto& mid : models) { + if (starts(mid)) found.push_back({TuiStr::u("@" + mid + " [model]"), TuiValue(mid)}); + } + if (src.complete_files) { + std::string dir = !directory.empty() ? directory : (src.dir_key ? src.dir_key() : std::string()); + for (auto& rel : src.complete_files(prefix, dir)) found.push_back({TuiStr::u("@" + rel + " [file]"), TuiValue(rel)}); + } + if (found.size() > 100) found.resize(100); + return found; +} + +std::vector TuiComplete::command(const UStr& prefix_u) { + std::string prefix = TuiStr::s(prefix_u); + const auto& help = command_help(); + std::vector found; + for (auto& name : sources().commands) { + if (name.compare(0, prefix.size(), prefix) != 0) continue; + auto it = help.find(name); + std::string desc = it != help.end() ? it->second : ""; + std::string label = !desc.empty() ? "/" + name + " - " + desc : "/" + name; + found.push_back({TuiStr::u(label), TuiValue(name)}); + } + return found; +} + +} // namespace detz::tui diff --git a/taicpp/src/tui/TuiComplete.h b/taicpp/src/tui/TuiComplete.h new file mode 100644 index 0000000..40f3e8a --- /dev/null +++ b/taicpp/src/tui/TuiComplete.h @@ -0,0 +1,41 @@ +#pragma once +// TuiComplete: completion sources for the text box popup (tai.py +// tui_command_help, tui_complete_mention, tui_complete_command, COMPLETE_WORD_RE, +// tui_avatar_role lives on TuiChatList). The command list, HELP_TEXT, provider +// labels and model lists belong to other modules, so the integrator fills +// TuiComplete::sources() once at boot (docs/wiring/tui.md). +#include "TuiOptions.h" +#include +#include +#include +#include +#include +namespace detz::tui { + +class TuiComplete { +public: + struct Sources { + std::vector commands; // COMMANDS + std::string help_text; // HELP_TEXT + std::vector provider_labels; // PROVIDER_LABELS + std::string mention_auto{"auto"}; // MENTION_AUTO + std::function()> default_models; // sorted(ZEN_FREE_MODELS | POLLINATIONS_MODELS | DEVPLACE_FREE_ROUTES | {OPENROUTER_MODEL}) + std::function(const std::string& prefix, const std::string& directory)> complete_files; + std::function dir_key; + }; + struct WordMatch { char32_t trigger; UStr word; size_t trigger_pos; }; + + static Sources& sources(); + static const std::map& command_help(); + static void reset_command_help(); + // COMPLETE_WORD_RE = (?:^|\s)([/@])([A-Za-z0-9_./-]*)$ searched in `text` + static std::optional word_match(const UStr& text); + static std::vector mention(const UStr& prefix, const std::string& directory = "", const std::vector& bots = {}, + const std::vector& models = {}); + static std::vector command(const UStr& prefix); +private: + static std::map help_; + static bool help_ready_; +}; + +} // namespace detz::tui diff --git a/taicpp/src/tui/TuiCompositor.cpp b/taicpp/src/tui/TuiCompositor.cpp new file mode 100644 index 0000000..1ed759c --- /dev/null +++ b/taicpp/src/tui/TuiCompositor.cpp @@ -0,0 +1,461 @@ +#include "TuiCompositor.h" +#include +#include +#include +namespace detz::tui { + +const char* const TUI_SYNC_START = "\x1b[?2026h"; +const char* const TUI_SYNC_END = "\x1b[?2026l"; + +namespace { +std::string cup(int y, int x) { + char buf[32]; + std::snprintf(buf, sizeof buf, "\x1b[%d;%dH", y, x); + return buf; +} +template V slice(const V& v, long a, long b) { + long n = static_cast(v.size()); + if (a < 0) a += n; + if (b < 0) b += n; + a = std::max(0L, std::min(a, n)); + b = std::max(0L, std::min(b, n)); + if (a >= b) return V(); + return V(v.begin() + a, v.begin() + b); +} +// python: target[a:b] = source (replaces the region, may change the length) +void splice(TuiCellRow& target, long a, long b, const TuiCellRow& source) { + long n = static_cast(target.size()); + a = std::max(0L, std::min(a, n)); + b = std::max(a, std::min(b, n)); + target.erase(target.begin() + a, target.begin() + b); + target.insert(target.begin() + a, source.begin(), source.end()); +} +} // namespace + +void TuiCompositor::invalidate() { + last.reset(); + sgr_memo_.clear(); +} + +std::vector TuiCompositor::flatten(TuiPanel* root) { + std::vector panels; + for (TuiPanel* node : root->walk()) { + if (node->is_shown() && node->aw > 0 && node->ah > 0) panels.push_back(node); + } + std::stable_sort(panels.begin(), panels.end(), [](TuiPanel* a, TuiPanel* b) { return stack_key(a) < stack_key(b); }); + return panels; +} + +std::vector> TuiCompositor::stack_key(TuiPanel* node) { + std::vector> parts; + while (node) { + parts.emplace_back(node->effective_z(), node->seq); + node = node->parent; + } + std::reverse(parts.begin(), parts.end()); + return parts; +} + +std::string TuiCompositor::sgr(int key) { + const TuiTheme* theme = TuiThemes::current().get(); + if (theme != sgr_theme_ || static_cast(sgr_memo_.size()) >= TUI_SGR_CAP) { + sgr_memo_.clear(); + sgr_theme_ = theme; + } + auto it = sgr_memo_.find(key); + if (it != sgr_memo_.end()) return it->second; + std::string code = TuiStyle::sgr_from_key(TuiStyleKeys::get(key)); + sgr_memo_[key] = code; + return code; +} + +TuiCellRows TuiCompositor::cells_from(const SpanRows& painted, int width) { + TuiCellRows out; + out.reserve(painted.size()); + for (auto& spans : painted) { + TuiCellRow row; + for (auto& span : spans) { + int key = TuiStyleKeys::intern(span.style->key()); + for (char32_t ch : span.text) row.push_back(TuiCell{ch, key}); + } + while (static_cast(row.size()) < width) row.push_back(TuiStyleKeys::blank()); + out.push_back(std::move(row)); + } + return out; +} + +TuiCell TuiCompositor::cell(char32_t ch, const TuiStyleKey& key) { return TuiCell{ch, TuiStyleKeys::intern(key)}; } + +std::shared_ptr TuiCompositor::cells_of(TuiPanel* panel, bool cached) { + std::pair size{panel->aw, panel->ah}; + if (cached && panel->cells_ && panel->cells_size_ == size) return panel->cells_; + std::shared_ptr cells; + try { + cells = std::make_shared(panel->paint_cells(*this)); + } catch (const std::exception&) { + return nullptr; + } + stats["painted"]++; + if (cached) { + panel->cells_ = cells; + panel->cells_size_ = size; + } + return cells; +} + +std::vector> TuiCompositor::gaps(const std::vector>& covered, int lo, int hi) { + std::vector> found; + int pos = lo; + for (auto& se : covered) { + int start = se.first, end = se.second; + if (end <= pos) continue; + if (start >= hi) break; + if (start > pos) found.emplace_back(pos, start); + pos = end; + if (pos >= hi) return found; + } + if (pos < hi) found.emplace_back(pos, hi); + return found; +} + +void TuiCompositor::claim(std::vector>& covered, int lo, int hi) { + covered.emplace_back(lo, hi); + std::sort(covered.begin(), covered.end()); + std::vector> merged{covered[0]}; + for (size_t i = 1; i < covered.size(); i++) { + if (covered[i].first <= merged.back().second) { + if (covered[i].second > merged.back().second) merged.back() = {merged.back().first, covered[i].second}; + } else { + merged.push_back(covered[i]); + } + } + covered = merged; +} + +TuiCellRows TuiCompositor::fill(const std::vector& stack, int x0, int y0, int x1, int y1, bool cached) { + int width = x1 - x0; + TuiCellRows rows(static_cast(std::max(0, y1 - y0)), TuiCellRow(static_cast(std::max(0, width)), TuiStyleKeys::blank())); + std::vector>> covers(static_cast(std::max(0, y1 - y0))); + long left = static_cast(width) * (y1 - y0); + for (auto it = stack.rbegin(); it != stack.rend(); ++it) { + TuiPanel* panel = *it; + if (left <= 0) break; + int cx0 = std::max(panel->clip[0], x0), cy0 = std::max(panel->clip[1], y0); + int cx1 = std::min(panel->clip[2], x1), cy1 = std::min(panel->clip[3], y1); + if (cx0 >= cx1 || cy0 >= cy1) continue; + std::vector>>> todo; + for (int y = cy0; y < cy1; y++) { + auto free_ = gaps(covers[static_cast(y - y0)], cx0, cx1); + if (!free_.empty()) todo.emplace_back(y, std::move(free_)); + } + if (todo.empty()) continue; + std::shared_ptr cells = cells_of(panel, cached); + if (!cells) continue; + int origin = panel->ax; + for (auto& yt : todo) { + int y = yt.first; + long local = y - panel->ay; + if (local < 0 || local >= static_cast(cells->size())) continue; + const TuiCellRow& source = (*cells)[static_cast(local)]; + TuiCellRow& target = rows[static_cast(y - y0)]; + for (auto& se : yt.second) { + splice(target, se.first - x0, se.second - x0, slice(source, se.first - origin, se.second - origin)); + left -= se.second - se.first; + } + claim(covers[static_cast(y - y0)], cx0, cx1); + } + } + stats["composed"] += static_cast(width) * (y1 - y0); + return rows; +} + +TuiCellRows TuiCompositor::compose(TuiPanel* root, int rows, int cols) { + return fill(flatten(root), 0, 0, cols, rows, false); +} + +TuiCellRows TuiCompositor::compose_reference(TuiPanel* root, int rows, int cols) { + std::vector panels = flatten(root); + struct Run { int x0, x1, origin; SpanRow spans; }; + std::map> runs_by_row; + for (TuiPanel* panel : panels) { + SpanRows painted; + try { + painted = panel->paint(); + } catch (const std::exception&) { + continue; // one broken widget garbles its own rect, never the frame + } + int x0 = std::max(0, panel->clip[0]), y0 = std::max(0, panel->clip[1]); + int x1 = std::min(cols, panel->clip[2]), y1 = std::min(rows, panel->clip[3]); + if (x0 >= x1 || y0 >= y1) continue; + for (size_t local = 0; local < painted.size(); local++) { + int ay = panel->ay + static_cast(local); + if (ay < y0 || ay >= y1) continue; + runs_by_row[ay].push_back(Run{std::max(panel->ax, x0), std::min(panel->ax + panel->aw, x1), panel->ax, painted[local]}); + } + } + TuiCellRows frame(static_cast(rows), TuiCellRow(static_cast(cols), TuiStyleKeys::blank())); + for (auto& entry : runs_by_row) { + int ay = entry.first; + auto& runs = entry.second; + std::set cuts; + for (auto& r : runs) { cuts.insert(r.x0); cuts.insert(r.x1); } + std::vector edges(cuts.begin(), cuts.end()); + for (size_t pos = 0; pos + 1 < edges.size(); pos++) { + int start = edges[pos], end = edges[pos + 1]; + if (start >= end) continue; + const Run* winner = nullptr; + for (auto& r : runs) if (r.x0 <= start && r.x1 >= end) winner = &r; + if (!winner) continue; + TuiCellRow& out_row = frame[static_cast(ay)]; + int col = winner->origin; + for (auto& span : winner->spans) { + int nxt = col + static_cast(span.text.size()); + int lo = std::max(col, start), hi = std::min(nxt, end); + if (lo < hi) { + int key = TuiStyleKeys::intern(span.style->key()); + for (int i = lo; i < hi; i++) out_row[static_cast(i)] = TuiCell{span.text[static_cast(i - col)], key}; + } + col = nxt; + if (col >= end) break; + } + } + } + return frame; +} + +void TuiCompositor::diff_row(std::string& out, int y, int x0, const TuiCellRow& new_row, const TuiCellRow& old_row, std::optional& current) { + int count = static_cast(new_row.size()); + int col = 0; + while (col < count) { + if (new_row[static_cast(col)] == old_row[static_cast(col)]) { col++; continue; } + int start = col; + while (col < count && new_row[static_cast(col)] != old_row[static_cast(col)]) col++; + out += cup(y + 1, x0 + start + 1); + for (int i = start; i < col; i++) { + const TuiCell& c = new_row[static_cast(i)]; + if (!current || *current != c.key) { + out += sgr(c.key); + current = c.key; + } + out += TuiStr::s(c.ch); + } + stats["emitted"] += col - start; + } +} + +std::string TuiCompositor::refresh(const std::vector& stack, int rows, int cols, std::optional> rects, + std::vector shifts) { + stats["frames"]++; + std::string out; + std::optional current; + if (!last || static_cast(last->size()) != rows || (rows > 0 && static_cast((*last)[0].size()) != cols)) { + last = TuiCellRows(static_cast(rows), TuiCellRow(static_cast(cols), TuiStyleKeys::never())); + rects.reset(); + } + if (!rects) { + rects = std::vector{TuiRect{{0, 0, cols, rows}}}; + shifts.clear(); + } + std::vector> fresh; + for (auto& rect : merge_rects(*rects, cols, rows)) fresh.emplace_back(rect, fill(stack, rect[0], rect[1], rect[2], rect[3], true)); + if (!shifts.empty()) { + scroll(stack, shifts, fresh, cols, rows, out); + current.reset(); + } + for (auto& fb : fresh) { + int x0 = fb.first[0], y0 = fb.first[1], x1 = fb.first[2]; + for (size_t offset = 0; offset < fb.second.size(); offset++) { + TuiCellRow& shown = (*last)[static_cast(y0) + offset]; + TuiCellRow shown_seg = slice(shown, x0, x1); + if (fb.second[offset] == shown_seg) continue; + diff_row(out, y0 + static_cast(offset), x0, fb.second[offset], shown_seg, current); + splice(shown, x0, x1, fb.second[offset]); + } + } + return out; +} + +long TuiCompositor::cost(const std::vector>& pairs) { + long total = 0; + for (auto& p : pairs) { + const TuiCellRow& nw = p.first; + const TuiCellRow& old = p.second; + if (nw == old) continue; + size_t n = std::min(nw.size(), old.size()); + std::vector mask(n); + long changed = 0, runs = 0; + bool prev = false; + std::vector keys; + for (size_t i = 0; i < n; i++) { + bool m = nw[i] != old[i]; + mask[i] = m; + if (m) { changed++; keys.push_back(nw[i].key); } + if (m && !prev) runs++; + prev = m; + } + long switches = 1; + for (size_t i = 0; i + 1 < keys.size(); i++) if (keys[i] != keys[i + 1]) switches++; + total += changed + TUI_MOVE_COST * runs + TUI_SWITCH_COST * switches; + } + return total; +} + +void TuiCompositor::scroll(const std::vector& stack, const std::vector& shifts, + std::vector>& fresh, int cols, int rows, std::string& out) { + size_t limit = std::min(shifts.size(), TUI_SCROLLS); + for (size_t si = 0; si < limit; si++) { + int x0 = std::max(0, shifts[si].x0), y0 = std::max(0, shifts[si].y0); + int x1 = std::min(cols, shifts[si].x1), y1 = std::min(rows, shifts[si].y1); + int dy = shifts[si].dy; + int height = y1 - y0; + int size = std::abs(dy); + if (x0 >= x1 || height < 3 || size >= height) continue; + const TuiCellRows* block = nullptr; + int rx0 = 0, ry0 = 0; + for (auto& fb : fresh) { + if (fb.first[0] <= x0 && fb.first[1] <= y0 && fb.first[2] >= x1 && fb.first[3] >= y1) { + rx0 = fb.first[0]; ry0 = fb.first[1]; block = &fb.second; + break; + } + } + if (!block) continue; + TuiCellRows& lst = *last; + std::vector new_rows; + for (int y = y0; y < y1; y++) new_rows.push_back(slice((*block)[static_cast(y - ry0)], x0 - rx0, x1 - rx0)); + int moved = 0; + if (dy > 0) { + for (int pos = 0; pos < height - size; pos++) if (new_rows[static_cast(pos)] == slice(lst[static_cast(y0 + pos + size)], x0, x1)) moved++; + } else { + for (int pos = size; pos < height; pos++) if (new_rows[static_cast(pos)] == slice(lst[static_cast(y0 + pos - size)], x0, x1)) moved++; + } + if (moved < 2) continue; + bool bounded = margins && (x0 > 0 || x1 < cols); + std::vector strips; + if (!bounded && x0 > 0) strips.push_back(TuiRect{{0, y0, x0, y1}}); + if (x1 < cols && !bounded) strips.push_back(TuiRect{{x1, y0, cols, y1}}); + std::vector> strip_blocks; + for (auto& r : strips) strip_blocks.emplace_back(r, fill(stack, r[0], r[1], r[2], r[3], true)); + TuiCellRow blank_row(static_cast(cols), TuiStyleKeys::blank()); + auto moved_row = [&](int y) -> TuiCellRow { + int src = dy > 0 ? y + size : y - size; + if (y0 <= src && src < y1) { + if (!bounded) return lst[static_cast(src)]; + TuiCellRow r = slice(lst[static_cast(y)], 0, x0); + TuiCellRow mid = slice(lst[static_cast(src)], x0, x1); + r.insert(r.end(), mid.begin(), mid.end()); + TuiCellRow tail = slice(lst[static_cast(y)], x1, static_cast(lst[static_cast(y)].size())); + r.insert(r.end(), tail.begin(), tail.end()); + return r; + } + if (!bounded) return blank_row; + TuiCellRow r = slice(lst[static_cast(y)], 0, x0); + TuiCellRow mid = slice(blank_row, x0, x1); + r.insert(r.end(), mid.begin(), mid.end()); + TuiCellRow tail = slice(lst[static_cast(y)], x1, static_cast(lst[static_cast(y)].size())); + r.insert(r.end(), tail.begin(), tail.end()); + return r; + }; + std::vector> now_pairs, then_pairs; + for (int pos = 0; pos < height; pos++) { + now_pairs.emplace_back(new_rows[static_cast(pos)], slice(lst[static_cast(y0 + pos)], x0, x1)); + then_pairs.emplace_back(new_rows[static_cast(pos)], slice(moved_row(y0 + pos), x0, x1)); + } + long now = cost(now_pairs); + long then = cost(then_pairs); + for (auto& sb : strip_blocks) { + std::vector> sp; + for (size_t pos = 0; pos < sb.second.size(); pos++) sp.emplace_back(sb.second[pos], slice(moved_row(y0 + static_cast(pos)), sb.first[0], sb.first[2])); + then += cost(sp); + } + if (then + TUI_SCROLL_SLACK + 2 * size >= now) continue; + char buf[96]; + std::snprintf(buf, sizeof buf, "\x1b[0m\x1b[%d;%dr", y0 + 1, y1); + std::string opening = buf; + std::string closing = "\x1b[r"; + int column = 1; + if (bounded) { + std::snprintf(buf, sizeof buf, "\x1b[?%dh\x1b[%d;%ds", TUI_DECLRMM, x0 + 1, x1); + opening += buf; + std::snprintf(buf, sizeof buf, "\x1b[?%dl", TUI_DECLRMM); + closing = std::string(buf) + closing; + column = x0 + 1; + } + std::string reps; + if (dy > 0) { + for (int i = 0; i < size; i++) reps += "\x1b" "D"; + out += opening + cup(y1, column) + reps + closing; + } else { + for (int i = 0; i < size; i++) reps += "\x1b" "M"; + out += opening + cup(y0 + 1, column) + reps + closing; + } + if (bounded) { + std::vector repl; + for (int y = y0; y < y1; y++) repl.push_back(moved_row(y)); + for (int y = y0; y < y1; y++) lst[static_cast(y)] = repl[static_cast(y - y0)]; + } else if (dy > 0) { + std::vector repl(lst.begin() + y0 + size, lst.begin() + y1); + for (int i = 0; i < size; i++) repl.push_back(TuiCellRow(static_cast(cols), TuiStyleKeys::blank())); + for (int y = y0; y < y1; y++) lst[static_cast(y)] = repl[static_cast(y - y0)]; + } else { + std::vector repl; + for (int i = 0; i < size; i++) repl.push_back(TuiCellRow(static_cast(cols), TuiStyleKeys::blank())); + repl.insert(repl.end(), lst.begin() + y0, lst.begin() + (y1 - size)); + for (int y = y0; y < y1; y++) lst[static_cast(y)] = repl[static_cast(y - y0)]; + } + stats["scrolled"] += height; + for (auto& sb : strip_blocks) fresh.push_back(std::move(sb)); + } +} + +std::vector TuiCompositor::merge_rects(const std::vector& rects, int cols, int rows) { + std::vector clamped; + for (auto& r : rects) { + TuiRect c{{std::max(0, r[0]), std::max(0, r[1]), std::min(cols, r[2]), std::min(rows, r[3])}}; + if (c[0] < c[2] && c[1] < c[3]) clamped.push_back(c); + } + std::stable_sort(clamped.begin(), clamped.end(), [](const TuiRect& a, const TuiRect& b) { + return (a[2] - a[0]) * (a[3] - a[1]) > (b[2] - b[0]) * (b[3] - b[1]); + }); + std::vector kept; + for (auto& rect : clamped) { + bool inside = false; + for (auto& big : kept) { + if (big[0] <= rect[0] && big[1] <= rect[1] && big[2] >= rect[2] && big[3] >= rect[3]) { inside = true; break; } + } + if (!inside) kept.push_back(rect); + } + return kept; +} + +std::string TuiCompositor::render(const TuiCellRows& frame) { + std::string out = std::string(TUI_SYNC_START) + "\x1b[?25l"; + if (!last) { + last = TuiCellRows(); + for (auto& row : frame) last->push_back(TuiCellRow(row.size(), TuiStyleKeys::never())); + } + std::optional current; + for (size_t row_pos = 0; row_pos < frame.size() && row_pos < last->size(); row_pos++) { + diff_row(out, static_cast(row_pos), 0, frame[row_pos], (*last)[row_pos], current); + } + out += "\x1b[0m"; + last = frame; + return out; +} + +std::string TuiCompositor::render_text(const TuiCellRows& frame) { + std::string out; + for (size_t i = 0; i < frame.size(); i++) { + UStr line; + for (auto& c : frame[i]) line.push_back(c.ch); + if (i) out += "\n"; + out += TuiStr::s(TuiStr::rstrip(line)); + } + return out; +} + +std::string TuiCompositor::place_cursor(int x, int y, bool visible) { + if (visible) return cup(y + 1, x + 1) + "\x1b[?25h"; + return "\x1b[?25l"; +} + +} // namespace detz::tui diff --git a/taicpp/src/tui/TuiCompositor.h b/taicpp/src/tui/TuiCompositor.h new file mode 100644 index 0000000..dedc472 --- /dev/null +++ b/taicpp/src/tui/TuiCompositor.h @@ -0,0 +1,72 @@ +#pragma once +// TuiCompositor: lazy compositor (tai.py TuiCompositor). Panels cache their +// painted cells, a frame is built front to back with per-row coverage so every +// cell is written once and a fully covered panel is never painted, and the +// screen it believes is shown persists, so an update recomposes and diffs only +// the damaged rects. compose_reference is the original full algorithm, the +// oracle the lazy path is verified against. Output is one single-write frame +// in synchronized-output markers. +// +// Declared difference: python interns cells in per-style pools (TuiCellPool) +// to make frame diffs identity-fast. Here a cell is (char, style key id), so +// equal cells compare by two integers; the pools and their caps are not needed. +#include "TuiCell.h" +#include "TuiPanel.h" +#include "TuiTheme.h" +#include +#include +#include +#include +#include +namespace detz::tui { + +constexpr int TUI_DECLRMM = 69; +constexpr int TUI_SWITCH_COST = 40; +constexpr int TUI_MOVE_COST = 8; +constexpr int TUI_SCROLL_SLACK = 80; +constexpr int TUI_SGR_CAP = 4096; +constexpr int TUI_DAMAGE_RECTS = 64; +constexpr int TUI_SCROLLS = 4; +extern const char* const TUI_SYNC_START; +extern const char* const TUI_SYNC_END; + +struct TuiShift { int x0, y0, x1, y1, dy; }; + +class TuiCompositor { +public: + TuiCompositor() = default; + std::optional last; + std::map stats{{"painted", 0}, {"composed", 0}, {"emitted", 0}, {"frames", 0}, {"scrolled", 0}}; + bool margins{false}; + + void invalidate(); + std::vector flatten(TuiPanel* root); + static std::vector> stack_key(TuiPanel* node); + std::string sgr(int key); + TuiCellRows cells_from(const SpanRows& painted, int width); + TuiCell cell(char32_t ch, const TuiStyleKey& key); + std::shared_ptr cells_of(TuiPanel* panel, bool cached); // null when the widget's paint raised + + static std::vector> gaps(const std::vector>& covered, int lo, int hi); + static void claim(std::vector>& covered, int lo, int hi); + TuiCellRows fill(const std::vector& stack, int x0, int y0, int x1, int y1, bool cached); + TuiCellRows compose(TuiPanel* root, int rows, int cols); + TuiCellRows compose_reference(TuiPanel* root, int rows, int cols); + void diff_row(std::string& out, int y, int x0, const TuiCellRow& new_row, const TuiCellRow& old_row, std::optional& current); + std::string refresh(const std::vector& stack, int rows, int cols, std::optional> rects, + std::vector shifts = {}); + static long cost(const std::vector>& pairs); + static std::vector merge_rects(const std::vector& rects, int cols, int rows); + std::string render(const TuiCellRows& frame); + static std::string render_text(const TuiCellRows& frame); + static std::string place_cursor(int x, int y, bool visible); + +private: + std::map sgr_memo_; + const TuiTheme* sgr_theme_{nullptr}; + struct Scrolled; + void scroll(const std::vector& stack, const std::vector& shifts, + std::vector>& fresh, int cols, int rows, std::string& out); +}; + +} // namespace detz::tui diff --git a/taicpp/src/tui/TuiDemo.cpp b/taicpp/src/tui/TuiDemo.cpp new file mode 100644 index 0000000..4c6f613 --- /dev/null +++ b/taicpp/src/tui/TuiDemo.cpp @@ -0,0 +1,328 @@ +#include "TuiDemo.h" +#include "TuiComplete.h" +#include +#include +#include +#include +#include +namespace detz::tui { + +namespace { +UStr now_iso() { + std::time_t t = std::time(nullptr); + std::tm tm{}; + gmtime_r(&t, &tm); + char buf[40]; + std::strftime(buf, sizeof buf, "%Y-%m-%dT%H:%M:%S+00:00", &tm); + return TuiStr::u(buf); +} +} // namespace + +std::pair> TuiDemo::build(TuiApp& app_ref, bool live) { + TuiApp* app = &app_ref; + auto refs = std::make_shared(); + refs->live = live; + // construction order follows tai.py exactly: panel seq numbers decide paint order between equal z siblings + TuiPanelOpts ro; + ro.flow.emplace(); + ro.style = mk_style(TuiColor(), "black"); + ro.id = "root"; + auto root = std::make_shared(ro); + TuiPanelOpts ho; + ho.dock = "top"; + ho.h = TuiSpec(1); + ho.style = mk_style("white", "blue", true); + ho.id = "header"; + auto header = std::make_shared(U" tai TUI demo -- layers, mouse, focus, animation", ho); + TuiPanelOpts fo; + fo.dock = "bottom"; + fo.h = TuiSpec(1); + fo.style = mk_style(TuiColor(), TuiColor(), false, true); + fo.id = "footer"; + auto footer = std::make_shared(U" tab/shift+tab focus - click options - q quit - n terminal - c close - F1 ask - 2xshift prompt - ctrl+c quit", fo); + root->add(header); + root->add(footer); + TuiPanelOpts so; + so.dock = "left"; + so.w = TuiSpec(28); + so.border = true; + so.title = UStr(U"Actions"); + so.id = "sidebar"; + so.gap = 1; + so.padding = TuiPanel::norm_padding(1); + auto sidebar = std::make_shared(so); + TuiPanelOpts ao; + ao.id = "actions"; + auto actions = std::make_shared( + std::vector{"Open question dialog", "Spawn floating terminal", "Toggle banner", "Close top overlay", "Quit demo"}, 0, false, nullptr, ao); + { + TuiPanelOpts to; + to.style = mk_style(TuiColor(), TuiColor(), false, true); + auto hint = std::make_shared(U"Pick an action,", to); + sidebar->add(hint); + sidebar->add(actions); + } + root->add(sidebar); + TuiPanelOpts sto; + sto.x = TuiSpec(0); + sto.y = TuiSpec(0); + sto.w = TuiSpec("100%"); + sto.h = TuiSpec("100%"); + sto.flow.emplace(); + sto.id = "stage"; + auto stage = std::make_shared(sto); + root->add(stage); + + TuiPanelOpts sho; + sho.title = UStr(U"shell (interactive, click to type)"); + sho.x = TuiSpec(0); + sho.y = TuiSpec(0); + sho.w = TuiSpec("100%"); + sho.h = TuiSpec("100%"); + sho.id = "shell"; + auto shell = std::make_shared(std::vector{}, true, sho); + stage->add(shell); + refs->shell = shell; + TuiPanelOpts sro; + sro.title = UStr(U"stream (read-only)"); + sro.x = TuiSpec("52%"); + sro.y = TuiSpec("6%"); + sro.w = TuiSpec("46%"); + sro.h = TuiSpec("42%"); + sro.z = 5; + sro.id = "stream"; + auto stream = std::make_shared( + std::vector{"sh", "-c", "i=0; while true; do i=$((i+1)); echo \"stream tick $i\"; sleep 1; done"}, false, sro); + stream->closable = true; + stage->add(stream); + refs->stream = stream; + TuiPanelOpts bo; + bo.x = TuiSpec(10); + bo.y = TuiSpec(1); + bo.z = 6; + bo.style = mk_style("white", "magenta", true); + bo.id = "banner"; + auto banner = std::make_shared(U" ** overlapping layers - dialogs - terminals - options - animation ** ", bo); + stage->add(banner); + refs->banner = banner; + TuiPanelOpts pbo; + pbo.title = UStr(U"Progress"); + pbo.x = TuiSpec(2); + pbo.y = TuiSpec("54%"); + pbo.w = TuiSpec(36); + pbo.h = TuiSpec(8); + pbo.z = 5; + pbo.border = true; + pbo.id = "progress"; + pbo.gap = 0; + pbo.padding = TuiPanel::norm_padding(0, 1); + auto prog_box = std::make_shared(pbo); + TuiPanelOpts pao; + pao.id = "prog_a"; + auto prog_a = std::make_shared(0.35, pao); + TuiPanelOpts pbo2; + pbo2.id = "prog_b"; + auto prog_b = std::make_shared(0.7, pbo2); + { + TuiPanelOpts to; + to.style = mk_style(TuiColor(), TuiColor(), false, true); + auto dl = std::make_shared(U"download", to); + auto ul = std::make_shared(U"upload", to); + prog_box->add(dl); + prog_box->add(prog_a); + prog_box->add(ul); + prog_box->add(prog_b); + } + prog_box->closable = true; + stage->add(prog_box); + refs->prog_a = prog_a; + refs->prog_b = prog_b; + TuiPanelOpts lo; + lo.title = UStr(U"Events (wheel to scroll)"); + lo.x = TuiSpec(40); + lo.y = TuiSpec("54%"); + lo.w = TuiSpec("42%"); + lo.h = TuiSpec(7); + lo.z = 5; + lo.border = true; + lo.id = "log"; + auto log = std::make_shared(lo); + log->closable = true; + log->append(U"demo started", mk_style("green")); + log->append(U"click the sidebar, or press F1", mk_style(TuiColor(), TuiColor(), false, true)); + log->append(U"tab cycles focus, shift+tab goes back", mk_style(TuiColor(), TuiColor(), false, true)); + stage->add(log); + refs->log = log; + + TuiPanel* root_p = root.get(); + TuiLog* log_p = log.get(); + std::weak_ptr root_w = root; + auto toast = [app, root_w, log_p](const UStr& text) { + auto r = root_w.lock(); + if (!r) return; + TuiPanelOpts no; + no.layer = "toast"; + no.x = TuiSpec(-2); + no.y = TuiSpec(1); + no.style = mk_style("black", "green", true); + auto note = std::make_shared(U" " + text + U" ", no); + note->closable = true; + r->add(note); + std::weak_ptr note_w = note; + app->after(3.0, [root_w, note_w] { + auto rr = root_w.lock(); + auto n = note_w.lock(); + if (rr && n && n->parent) rr->remove(n); + }); + log_p->append(text); + }; + + auto ask_question = [refs, root_p, toast]() { + std::vector opts{"Ocean", "Forest", "Sunset", "Mono"}; + TuiPanelOpts qo; + qo.title = UStr(U"Question"); + qo.w = TuiSpec(44); + if (refs->first_ask) qo.id = "welcome"; + auto q = std::make_shared(U"Pick a theme for the demo:", opts, + [toast](const TuiValue& value) { toast(U"you picked: " + TuiStr::u(value.str())); }, false, qo); + root_p->add(q); + refs->first_ask = false; + }; + + auto open_prompt = [app, refs, root_p, toast, log_p, shell]() -> bool { + (void)refs; + if (root_p->find("prompt")) return true; + auto execute = [toast, log_p, shell](const UStr& text) { + shell->send_text(text + U"\n"); + UStr first_line = TuiStr::split(text, U'\n')[0]; + UStr first = TuiStr::head(first_line, 40); + if (first.empty()) first = U"(empty)"; + toast(U"executing: " + first); + log_p->append(U"prompt: " + TuiStr::head(first_line, 60)); + }; + TuiPanelOpts po; + po.id = "prompt"; + auto dlg = std::make_shared( + U"Enter prompt (/ commands, @ mentions):", 3, execute, + [](const UStr& prefix) { return TuiComplete::command(prefix); }, + [](const UStr& prefix) { return TuiComplete::mention(prefix); }, po); + root_p->add(dlg); + (void)app; + return true; + }; + app->on_double_tap = [open_prompt](TuiKey& event) -> bool { + if (event.key == "shift") return open_prompt(); + return false; + }; + + std::weak_ptr stage_w = stage; + auto spawn_terminal = [refs, live, stage_w, log_p]() -> std::shared_ptr { + refs->term_count++; + int nth = refs->term_count; + TuiPanelOpts to; + to.title = TuiStr::u("shell #" + std::to_string(nth + 1)); + to.x = TuiSpec(6 + nth * 4); + to.y = TuiSpec(3 + nth * 2); + to.w = TuiSpec(54); + to.h = TuiSpec(14); + to.z = 5; + to.id = "term" + std::to_string(nth); + auto term = std::make_shared(std::vector{}, true, to); + term->closable = true; + stage_w.lock()->add(term); + if (live) term->spawn(); + else term->screen.feed("$ \r\n"); + term->focus(); + log_p->append(TuiStr::u("spawned terminal #" + std::to_string(nth + 1))); + return term; + }; + + auto close_top = [app, root_p, log_p]() { + TuiPanel* best = nullptr; + for (TuiPanel* node : root_p->walk()) { + if (node->closable && node->is_shown() && node->enabled()) { + if (!best || std::make_pair(node->effective_z(), node->seq) > std::make_pair(best->effective_z(), best->seq)) best = node; + } + } + if (best) { + UStr name = !best->title.empty() ? best->title : (!best->id.empty() ? TuiStr::u(best->id) : UStr(U"panel")); + log_p->append(U"closed " + name); + app->close_panel(best); + } + }; + + actions->set_handler("select", [refs, ask_question, spawn_terminal, close_top, app, banner](TuiEvent& e) { + auto& event = static_cast(e); + if (event.index == 0) ask_question(); + else if (event.index == 1) spawn_terminal(); + else if (event.index == 2) { + refs->banner_on = !refs->banner_on; + banner->set_visible(refs->banner_on); + } else if (event.index == 3) close_top(); + else if (event.index == 4) app->quit(); + }); + + root->set_handler("key", [app, spawn_terminal, close_top, ask_question](TuiEvent& e) { + auto& event = static_cast(e); + if (event.key == "q") { event.stop(); app->quit(); } + else if (event.key == "n") { event.stop(); spawn_terminal(); } + else if (event.key == "c") { event.stop(); close_top(); } + else if (event.key == "f1") { event.stop(); ask_question(); } + }); + refs->ask = ask_question; + refs->spawn = spawn_terminal; + refs->close_top = close_top; + + if (live) { + TuiText* header_p = header.get(); + app->every(1.0, [header_p] { header_p->set_text(U" tai TUI demo -- " + now_iso()); }); + TuiProgress* pa = prog_a.get(); + TuiProgress* pb = prog_b.get(); + app->every(0.12, [pa, pb] { + pa->set(std::fmod(pa->value() + 0.02, 1.0)); + pb->set(std::fmod(pb->value() + 0.013, 1.0)); + }); + TuiPanel* stage_p = stage.get(); + TuiText* banner_p = banner.get(); + app->every(0.06, [refs, stage_p, banner_p] { + if (!refs->banner_on) return; + int room = std::max(1, stage_p->aw - 68); + long secs = static_cast(std::chrono::duration(std::chrono::system_clock::now().time_since_epoch()).count() * 14); + banner_p->set_x(TuiSpec(static_cast(1 + secs % room))); + }); + app->every(5.0, [refs, log_p] { + refs->log_count++; + log_p->append(TuiStr::u("heartbeat " + std::to_string(refs->log_count)), mk_style(TuiColor(), TuiColor(), false, true)); + }); + } + ask_question(); + return {root, refs}; +} + +int TuiDemo::demo() { + TuiApp app; + try { + auto built = build(app, true); + app.mount(built.first); + for (TuiTerminal* term : app.terminals()) term->spawn(); + return app.run(); + } catch (const TuiError& exc) { + std::cerr << "demo unavailable: " << exc.what() << "\n"; + return 2; + } +} + +int TuiDemo::demo_shot(std::ostream& out, int rows, int cols) { + TuiApp app; + app.rows = rows; + app.cols = cols; + auto built = build(app, false); + app.mount(built.first); + app.close_panel(built.first->find("welcome")); + built.second->shell->screen.feed("$ echo hello\r\nhello\r\n$ "); + built.second->stream->screen.feed("stream tick 1\r\nstream tick 2\r\nstream tick 3\r\n"); + out << app.render_text() << "\n"; + return 0; +} + +} // namespace detz::tui diff --git a/taicpp/src/tui/TuiDemo.h b/taicpp/src/tui/TuiDemo.h new file mode 100644 index 0000000..1002299 --- /dev/null +++ b/taicpp/src/tui/TuiDemo.h @@ -0,0 +1,36 @@ +#pragma once +// TuiDemo: the /demo scene (tai.py tui_build_demo, tui_demo, tui_demo_shot): +// every widget, overlapping layers, timers, modals. live=false builds the +// identical tree with canned terminal bytes for headless --demo-shot instead +// of spawning real ptys. +#include "TuiApp.h" +#include "TuiDialog.h" +#include "TuiLog.h" +#include "TuiProgress.h" +#include "TuiTerminal.h" +#include "TuiText.h" +#include +#include +namespace detz::tui { + +class TuiDemo { +public: + struct Refs { + bool live{true}; + bool banner_on{true}; + int term_count{0}; + int log_count{0}; + bool first_ask{true}; + std::shared_ptr shell, stream; + std::shared_ptr banner; + std::shared_ptr prog_a, prog_b; + std::shared_ptr log; + std::function ask, close_top; + std::function()> spawn; + }; + static std::pair> build(TuiApp& app, bool live = true); + static int demo(); // ./tai.py --demo + static int demo_shot(std::ostream& out, int rows = 30, int cols = 100); // ./tai.py --demo-shot +}; + +} // namespace detz::tui diff --git a/taicpp/src/tui/TuiDialog.cpp b/taicpp/src/tui/TuiDialog.cpp new file mode 100644 index 0000000..c2a9684 --- /dev/null +++ b/taicpp/src/tui/TuiDialog.cpp @@ -0,0 +1,91 @@ +#include "TuiDialog.h" +#include "TuiApp.h" +namespace detz::tui { + +TuiDialog::TuiDialog(TuiPanelOpts opts, std::function on_close, bool closable_flag) + : TuiPanel([&] { + if (!opts.layer) opts.layer = "dialog"; + if (!opts.modal) opts.modal = true; + if (!opts.border) opts.border = true; + if (!opts.x) opts.x = TuiSpec("center"); + if (!opts.y) opts.y = TuiSpec("center"); + return opts; + }()) { + closable = closable_flag; + if (on_close) set_handler("close", [fn = std::move(on_close)](TuiEvent& e) { fn(static_cast(e)); }); +} + +std::shared_ptr TuiDialog::buttons(const std::vector>& specs) { + TuiPanelOpts o; + o.gap = 2; + auto row = std::make_shared(o); + for (auto& spec : specs) row->add(std::make_shared(spec.first, spec.second)); + add(row); + return row; +} + +void TuiDialog::on_key(TuiKey& event) { + if (event.key == "escape") { + event.stop(); + close(); + } +} + +void TuiDialog::on_mount(TuiMount&) { + if (app) { + app->remember_focus(); + app->focus_first(this); + } +} + +TuiQuestion::TuiQuestion(UStr prompt_text, const std::vector& options, std::function on_done_fn, bool multi, + TuiPanelOpts opts) + : TuiDialog(std::move(opts)), on_done(std::move(on_done_fn)) { + prompt = std::make_shared(std::move(prompt_text), TuiPanelOpts{}, "left", true); + add(prompt); + picker = std::make_shared(options, 0, multi); + add(picker); + if (multi) { + buttons({{U"OK", [this](TuiEvent&) { finish(); }}, {U"Cancel", [this](TuiEvent&) { close(); }}}); + } else { + buttons({{U"Cancel", [this](TuiEvent&) { close(); }}}); + } +} + +void TuiQuestion::on_select(TuiSelect& event) { + if (event.panel != picker.get() || picker->multi()) return; + event.stop(); + if (on_done) on_done(event.value); + close(); +} + +void TuiQuestion::finish() { + TuiValue values = TuiValue::list(); + for (long pos : picker->chosen) values.push(picker->options[static_cast(pos)].value); + if (on_done) on_done(values); + close(); +} + +TuiPromptDialog::TuiPromptDialog(UStr label, int box_h, std::function on_execute_fn, TuiTextBox::CompleteFn complete, + TuiTextBox::CompleteFn mentions, TuiPanelOpts opts) + : TuiDialog([&] { + if (!opts.title) opts.title = UStr(U"Prompt"); + if (!opts.w) opts.w = TuiSpec(56); + return opts; + }()), + on_execute(std::move(on_execute_fn)) { + add(std::make_shared(std::move(label), [] { TuiPanelOpts o; o.style = mk_style(TuiColor(), TuiColor(), false, true); return o; }())); + TuiPanelOpts bo; + bo.h = TuiSpec(box_h); + bo.style = mk_style("white", "black"); // python: TuiStyle(bg="black", fg="white") + box = std::make_shared(UStr(), std::move(complete), std::move(mentions), false, nullptr, 0, UStr(), nullptr, bo); + add(box); + buttons({{U"OK", [this](TuiEvent&) { run(); }}, {U"Cancel", [this](TuiEvent&) { close(); }}}); +} + +void TuiPromptDialog::run() { + if (on_execute) on_execute(box->value()); + close(); +} + +} // namespace detz::tui diff --git a/taicpp/src/tui/TuiDialog.h b/taicpp/src/tui/TuiDialog.h new file mode 100644 index 0000000..d2c751a --- /dev/null +++ b/taicpp/src/tui/TuiDialog.h @@ -0,0 +1,42 @@ +#pragma once +// TuiDialog (centered modal panel with a button row, Esc closes), TuiQuestion +// (prompt plus clickable/keyboard options, answers via on_done(value)) and +// TuiPromptDialog (label, multiline editor, OK/Cancel). tai.py TuiDialog, +// TuiQuestion, TuiPromptDialog. +#include "TuiButton.h" +#include "TuiOptions.h" +#include "TuiText.h" +#include "TuiTextBox.h" +namespace detz::tui { + +class TuiDialog : public TuiPanel { +public: + explicit TuiDialog(TuiPanelOpts opts = {}, std::function on_close = nullptr, bool closable = true); + std::shared_ptr buttons(const std::vector>& specs); + void on_key(TuiKey& event) override; + void on_mount(TuiMount& event) override; +}; + +class TuiQuestion : public TuiDialog { +public: + TuiQuestion(UStr prompt, const std::vector& options, std::function on_done = nullptr, bool multi = false, + TuiPanelOpts opts = {}); + std::function on_done; + std::shared_ptr prompt; + std::shared_ptr picker; + void on_select(TuiSelect& event) override; +private: + void finish(); +}; + +class TuiPromptDialog : public TuiDialog { +public: + TuiPromptDialog(UStr label = U"Enter prompt:", int box_h = 3, std::function on_execute = nullptr, + TuiTextBox::CompleteFn complete = nullptr, TuiTextBox::CompleteFn mentions = nullptr, TuiPanelOpts opts = {}); + std::function on_execute; + std::shared_ptr box; +private: + void run(); +}; + +} // namespace detz::tui diff --git a/taicpp/src/tui/TuiDoubleTap.h b/taicpp/src/tui/TuiDoubleTap.h new file mode 100644 index 0000000..4bc9b0d --- /dev/null +++ b/taicpp/src/tui/TuiDoubleTap.h @@ -0,0 +1,28 @@ +#pragma once +// TuiDoubleTap: two same-key presses inside a window (tai.py TuiDoubleTap). +// The clock is injected (now) so tests stay deterministic. +#include +namespace detz::tui { + +class TuiDoubleTap { +public: + explicit TuiDoubleTap(double timeout = 0.4) : timeout(timeout) {} + bool hit(const std::string& key, double now) { + if (have_last && key == last_key && (now - last_at) <= timeout) { + have_last = false; + last_key.clear(); + last_at = 0.0; + return true; + } + last_key = key; + have_last = true; + last_at = now; + return false; + } + double timeout; + std::string last_key; + bool have_last{false}; // python last_key None + double last_at{0.0}; +}; + +} // namespace detz::tui diff --git a/taicpp/src/tui/TuiEvent.h b/taicpp/src/tui/TuiEvent.h new file mode 100644 index 0000000..a1eb10c --- /dev/null +++ b/taicpp/src/tui/TuiEvent.h @@ -0,0 +1,127 @@ +#pragma once +// TuiEvent and its subclasses (tai.py TuiEvent, TuiKey, TuiClick, TuiScroll, +// TuiFocus, TuiBlur, TuiResize, TuiTick, TuiModeReply, TuiMount, TuiUnmount, +// TuiSelect, TuiPress, TuiClose, TuiPaste, TuiChange). Python events take +// arbitrary kwargs; the fields tai.py reads are typed members, anything else +// goes in `extra`. The tiny event classes share one header on purpose: each is +// a name, a bubble flag and a handful of fields. +#include "TuiValue.h" +#include "TuiStr.h" +#include +#include +namespace detz::tui { + +class TuiPanel; + +class TuiEvent { +public: + virtual ~TuiEvent() = default; + virtual const char* name() const { return "event"; } + virtual bool bubble() const { return true; } + std::string handler_name() const { return std::string("on_") + name(); } + void stop() { stopped = true; } + TuiPanel* target{nullptr}; + bool stopped{false}; + TuiPanel* panel{nullptr}; // panel= kwarg on mount/focus/press/select/close/change/resize/tick + TuiValue extra = TuiValue::dict(); +}; + +class TuiKey : public TuiEvent { +public: + TuiKey(std::string key = "", UStr ch = UStr(), std::vector modifiers = {}) + : key(std::move(key)), ch(std::move(ch)), modifiers(std::move(modifiers)) {} + const char* name() const override { return "key"; } + std::string key; + UStr ch; // python event.char + std::vector modifiers; +}; + +class TuiClick : public TuiEvent { +public: + TuiClick(int x = 0, int y = 0, int button = 0, std::vector modifiers = {}, bool pressed = false) + : x(x), y(y), button(button), modifiers(std::move(modifiers)), pressed(pressed) {} + const char* name() const override { return "click"; } + int x, y, button; + std::vector modifiers; + bool pressed; +}; + +class TuiScroll : public TuiEvent { +public: + TuiScroll(int x = 0, int y = 0, std::string direction = "", std::vector modifiers = {}) + : x(x), y(y), direction(std::move(direction)), modifiers(std::move(modifiers)) {} + const char* name() const override { return "scroll"; } + int x, y; + std::string direction; + std::vector modifiers; +}; + +class TuiFocus : public TuiEvent { +public: + const char* name() const override { return "focus"; } + bool bubble() const override { return false; } +}; +class TuiBlur : public TuiEvent { +public: + const char* name() const override { return "blur"; } + bool bubble() const override { return false; } +}; +class TuiResize : public TuiEvent { +public: + const char* name() const override { return "resize"; } + bool bubble() const override { return false; } + int rows{0}, cols{0}; +}; +class TuiTick : public TuiEvent { +public: + const char* name() const override { return "tick"; } + bool bubble() const override { return false; } + double dt{0.0}, now{0.0}; +}; +// The terminal's answer to a DECRQM query: consumed by the app, never delivered to panels. +class TuiModeReply : public TuiEvent { +public: + TuiModeReply(int mode = 0, int state = 0) : mode(mode), state(state) {} + const char* name() const override { return "mode_reply"; } + bool bubble() const override { return false; } + int mode, state; +}; +class TuiMount : public TuiEvent { +public: + const char* name() const override { return "mount"; } + bool bubble() const override { return false; } +}; +class TuiUnmount : public TuiEvent { +public: + const char* name() const override { return "unmount"; } + bool bubble() const override { return false; } +}; +class TuiSelect : public TuiEvent { +public: + const char* name() const override { return "select"; } + long index{0}; + std::string option; // label + TuiValue value; +}; +class TuiPress : public TuiEvent { +public: + const char* name() const override { return "press"; } +}; +class TuiClose : public TuiEvent { +public: + const char* name() const override { return "close"; } +}; +class TuiPaste : public TuiEvent { +public: + TuiPaste(UStr text = UStr()) : text(std::move(text)) {} + const char* name() const override { return "paste"; } + UStr text; +}; +class TuiChange : public TuiEvent { +public: + const char* name() const override { return "change"; } + bool bubble() const override { return false; } + long rows{0}; +}; + +} // namespace detz::tui diff --git a/taicpp/src/tui/TuiHistory.cpp b/taicpp/src/tui/TuiHistory.cpp new file mode 100644 index 0000000..b4c73e1 --- /dev/null +++ b/taicpp/src/tui/TuiHistory.cpp @@ -0,0 +1,60 @@ +#include "TuiHistory.h" +#include "TuiStr.h" +#include +namespace detz::tui { + +TuiHistory::TuiHistory(std::shared_ptr b, size_t c) : backend(std::move(b)), cap(std::max(1, c)) { + if (backend) { + try { + auto loaded = backend->load(); + if (loaded.size() > cap) loaded.erase(loaded.begin(), loaded.end() - static_cast(cap)); + entries = std::move(loaded); + } catch (const std::exception&) { + entries.clear(); + } + } +} + +void TuiHistory::add(const std::string& raw) { + std::string text = TuiStr::strip_ascii(raw); + reset(); + if (text.empty() || (!entries.empty() && entries.back() == text)) return; + entries.push_back(text); + if (entries.size() > cap) entries.erase(entries.begin(), entries.end() - static_cast(cap)); + if (backend) { + try { + backend->append(text); + } catch (const std::exception&) { + write_errors++; + } + } +} + +void TuiHistory::reset() { + pos.reset(); + draft.clear(); +} + +std::optional TuiHistory::older(const std::string& current) { + if (entries.empty()) return std::nullopt; + if (!pos) { + draft = current; + pos = entries.size(); + } + if (*pos == 0) return std::nullopt; + pos = *pos - 1; + return entries[*pos]; +} + +std::optional TuiHistory::newer() { + if (!pos) return std::nullopt; + pos = *pos + 1; + if (*pos >= entries.size()) { + std::string d = draft; + reset(); + return d; + } + return entries[*pos]; +} + +} // namespace detz::tui diff --git a/taicpp/src/tui/TuiHistory.h b/taicpp/src/tui/TuiHistory.h new file mode 100644 index 0000000..2800420 --- /dev/null +++ b/taicpp/src/tui/TuiHistory.h @@ -0,0 +1,57 @@ +#pragma once +// TuiHistory: input history with readline semantics (tai.py TuiHistory): +// up recalls older entries, down walks back toward the unfinished draft you +// started from. Persistence is a pluggable backend with load() and +// append(text); without one it lives in memory only. Capped at +// INPUT_HISTORY_CAP entries. +#include +#include +#include +#include +#include +namespace detz::tui { + +constexpr size_t INPUT_HISTORY_CAP = 1000; + +class TuiHistoryBackend { +public: + virtual ~TuiHistoryBackend() = default; + virtual std::vector load() = 0; + virtual void append(const std::string& text) = 0; +}; + +// VaultHistory: the backend over the vault's input_history table. The vault is +// another module's job, so the three store calls are injected (wiring/tui.md). +class VaultHistory : public TuiHistoryBackend { +public: + VaultHistory(std::function(const std::string& profile)> load_fn, + std::function add_fn, + std::string profile, std::function bot = [] { return std::string("main"); }) + : load_fn_(std::move(load_fn)), add_fn_(std::move(add_fn)), profile_(std::move(profile)), bot_(std::move(bot)) {} + std::vector load() override { return load_fn_(profile_); } + void append(const std::string& text) override { add_fn_(profile_, bot_(), text); } + void set_bot(std::function bot) { bot_ = std::move(bot); } +private: + std::function(const std::string&)> load_fn_; + std::function add_fn_; + std::string profile_; + std::function bot_; +}; + +class TuiHistory { +public: + explicit TuiHistory(std::shared_ptr backend = nullptr, size_t cap = INPUT_HISTORY_CAP); + void add(const std::string& text); + void reset(); + std::optional older(const std::string& current); + std::optional newer(); + + std::shared_ptr backend; + size_t cap; + std::vector entries; + std::optional pos; + std::string draft; + long write_errors{0}; +}; + +} // namespace detz::tui diff --git a/taicpp/src/tui/TuiHitMap.cpp b/taicpp/src/tui/TuiHitMap.cpp new file mode 100644 index 0000000..6b953d3 --- /dev/null +++ b/taicpp/src/tui/TuiHitMap.cpp @@ -0,0 +1,35 @@ +#include "TuiHitMap.h" +namespace detz::tui { + +namespace { +int floor_div(int a, int b) { int q = a / b; return (a % b != 0 && ((a < 0) != (b < 0))) ? q - 1 : q; } +} + +void TuiHitMap::rebuild(const std::vector& panels) { pending_ = panels; } + +void TuiHitMap::build() { + std::map, std::vector> grid; + for (TuiPanel* panel : *pending_) { + int x0 = panel->clip[0], y0 = panel->clip[1], x1 = panel->clip[2], y1 = panel->clip[3]; + if (x0 >= x1 || y0 >= y1) continue; + for (int gy = floor_div(y0, CELL_H); gy < floor_div(y1 - 1, CELL_H) + 1; gy++) { + for (int gx = floor_div(x0, CELL_W); gx < floor_div(x1 - 1, CELL_W) + 1; gx++) grid[{gx, gy}].push_back(panel); + } + } + grid_ = std::move(grid); + pending_.reset(); +} + +TuiPanel* TuiHitMap::at(int x, int y) { + if (pending_) build(); + auto it = grid_.find({floor_div(x, CELL_W), floor_div(y, CELL_H)}); + if (it == grid_.end()) return nullptr; + for (auto p = it->second.rbegin(); p != it->second.rend(); ++p) { + TuiPanel* panel = *p; + if (!panel->is_shown()) continue; + if (panel->clip[0] <= x && x < panel->clip[2] && panel->clip[1] <= y && y < panel->clip[3]) return panel; + } + return nullptr; +} + +} // namespace detz::tui diff --git a/taicpp/src/tui/TuiHitMap.h b/taicpp/src/tui/TuiHitMap.h new file mode 100644 index 0000000..bc1c872 --- /dev/null +++ b/taicpp/src/tui/TuiHitMap.h @@ -0,0 +1,24 @@ +#pragma once +// TuiHitMap: cell-grid index over painted panels so hit-testing stays cheap +// no matter how many layers stack up (tai.py TuiHitMap). Lazy: a layout only +// hands over its stack; the grid is built by the first click or wheel event +// after it. +#include "TuiPanel.h" +#include +#include +#include +namespace detz::tui { + +class TuiHitMap { +public: + static constexpr int CELL_W = 8; + static constexpr int CELL_H = 4; + void rebuild(const std::vector& panels); + void build(); + TuiPanel* at(int x, int y); +private: + std::map, std::vector> grid_; + std::optional> pending_; +}; + +} // namespace detz::tui diff --git a/taicpp/src/tui/TuiInput.cpp b/taicpp/src/tui/TuiInput.cpp new file mode 100644 index 0000000..431aa43 --- /dev/null +++ b/taicpp/src/tui/TuiInput.cpp @@ -0,0 +1,390 @@ +#include "TuiInput.h" +#include +namespace detz::tui { + +namespace { +long tui_int(const std::string& s, long dflt) { + // python int(str) for the ascii inputs seen here; whitespace tolerated, sign allowed + size_t b = 0, e = s.size(); + while (b < e && (s[b] == ' ' || s[b] == '\t')) b++; + while (e > b && (s[e - 1] == ' ' || s[e - 1] == '\t')) e--; + if (b >= e) return dflt; + size_t i = b; + if (s[i] == '+' || s[i] == '-') i++; + if (i >= e) return dflt; + for (size_t k = i; k < e; k++) if (s[k] < '0' || s[k] > '9') return dflt; + return std::strtol(s.substr(b, e - b).c_str(), nullptr, 10); +} +bool all_digits(const std::string& s) { + if (s.empty()) return false; + for (char c : s) if (c < '0' || c > '9') return false; + return true; +} +std::vector split(const std::string& s, char sep) { + std::vector out; + size_t from = 0; + while (true) { + size_t at = s.find(sep, from); + if (at == std::string::npos) { out.push_back(s.substr(from)); break; } + out.push_back(s.substr(from, at - from)); + from = at + 1; + } + return out; +} +std::string join_key(const std::vector& mods, const std::string& base) { + std::string out; + for (auto& m : mods) out += m + "+"; + return out + base; +} +TuiInput::Parsed make_event(EventP ev, std::string rest) { + TuiInput::Parsed p; + p.kind = ev ? TuiInput::Parsed::Event : TuiInput::Parsed::Nothing; + p.event = std::move(ev); + p.rest = std::move(rest); + return p; +} +TuiInput::Parsed need_more(const std::string& buf) { + TuiInput::Parsed p; + p.kind = TuiInput::Parsed::NeedMore; + p.rest = buf; + return p; +} +TuiInput::Parsed escape(std::string rest) { + TuiInput::Parsed p; + p.kind = TuiInput::Parsed::Escape; + p.rest = std::move(rest); + return p; +} +UStr char_text(char32_t c) { return UStr(1, c); } +} // namespace + +const std::map& TuiInput::csi_keys() { + static const std::map m = {{"A", "up"}, {"B", "down"}, {"C", "right"}, {"D", "left"}, {"H", "home"}, {"F", "end"}, {"Z", "backtab"}}; + return m; +} +const std::map& TuiInput::tilde_keys() { + static const std::map m = {{"1", "home"}, {"2", "insert"}, {"3", "delete"}, {"4", "end"}, {"5", "pageup"}, {"6", "pagedown"}, + {"7", "home"}, {"8", "end"}, {"11", "f1"}, {"12", "f2"}, {"13", "f3"}, {"14", "f4"}, {"15", "f5"}, + {"17", "f6"}, {"18", "f7"}, {"19", "f8"}, {"20", "f9"}, {"21", "f10"}, {"23", "f11"}, {"24", "f12"}}; + return m; +} +const std::map& TuiInput::ss3_keys() { + static const std::map m = {{'P', "f1"}, {'Q', "f2"}, {'R', "f3"}, {'S', "f4"}, {'A', "up"}, {'B', "down"}, {'C', "right"}, {'D', "left"}, {'H', "home"}, {'F', "end"}}; + return m; +} + +static const std::map& kitty_named() { + static const std::map m = {{27, "escape"}, {13, "enter"}, {9, "tab"}, {127, "backspace"}, {57414, "enter"}, + {57417, "left"}, {57418, "right"}, {57419, "up"}, {57420, "down"}, {57421, "pageup"}, + {57422, "pagedown"}, {57423, "home"}, {57424, "end"}, {57425, "insert"}, {57426, "delete"}}; + return m; +} +static const std::map& kitty_mods() { + static const std::map m = {{57441, "shift"}, {57447, "shift"}, {57442, "ctrl"}, {57448, "ctrl"}, + {57443, "alt"}, {57449, "alt"}, {57444, "super"}, {57450, "super"}}; + return m; +} +static const std::map& kitty_pad() { + static const std::map m = {{57400, "0"}, {57401, "1"}, {57402, "2"}, {57403, "3"}, {57404, "4"}, {57405, "5"}, + {57406, "6"}, {57407, "7"}, {57408, "8"}, {57409, "."}, {57410, "/"}, {57411, "*"}, + {57412, "-"}, {57413, "+"}, {57415, "="}}; + return m; +} +static const std::map& kitty_us_shift() { + static const std::map m = {{'1', '!'}, {'2', '@'}, {'3', '#'}, {'4', '$'}, {'5', '%'}, {'6', '^'}, {'7', '&'}, + {'8', '*'}, {'9', '('}, {'0', ')'}, {'-', '_'}, {'=', '+'}, {'[', '{'}, {']', '}'}, + {'\\', '|'}, {';', ':'}, {'\'', '"'}, {',', '<'}, {'.', '>'}, {'/', '?'}, {'`', '~'}}; + return m; +} + +std::vector TuiInput::modifiers(int code) { + std::vector mods; + int bits = code - 1; + if (bits & 1) mods.push_back("shift"); + if (bits & 2) mods.push_back("alt"); + if (bits & 4) mods.push_back("ctrl"); + if (bits & 8) mods.push_back("meta"); + return mods; +} + +TuiInput::Utf8Head TuiInput::utf8_head(const std::string& buf) { + unsigned char first = static_cast(buf[0]); + if (first < 0x80) return {first, 1, false}; + size_t need; + if (first >= 0xC2 && first <= 0xDF) need = 2; + else if (first >= 0xE0 && first <= 0xEF) need = 3; + else if (first >= 0xF0 && first <= 0xF4) need = 4; + else return {0xFFFD, 1, false}; + if (buf.size() < need) return {0, 0, true}; + // strict decode of exactly `need` bytes (python bytes.decode("utf-8")) + char32_t cp = first & (need == 2 ? 0x1F : need == 3 ? 0x0F : 0x07); + for (size_t k = 1; k < need; k++) { + unsigned char cc = static_cast(buf[k]); + if ((cc & 0xC0) != 0x80) return {0xFFFD, 1, false}; + cp = (cp << 6) | (cc & 0x3F); + } + if ((need == 3 && cp < 0x800) || (need == 4 && (cp < 0x10000 || cp > 0x10FFFF)) || (cp >= 0xD800 && cp <= 0xDFFF)) return {0xFFFD, 1, false}; + return {cp, need, false}; +} + +EventP TuiInput::parse_mouse_sgr(const std::string& text, bool* ok) { + bool pressed = !text.empty() && text.back() == 'M'; + bool wellformed = !text.empty() && text[0] == '<' && (text.back() == 'M' || text.back() == 'm'); + if (!wellformed) { if (ok) *ok = false; return nullptr; } + std::string body = text.substr(1, text.size() - 2); + auto parts = split(body, ';'); + if (parts.size() != 3) { if (ok) *ok = false; return nullptr; } + long v[3]; + for (int i = 0; i < 3; i++) { + // python int(): optional sign, digits; anything else is ValueError + const std::string& p = parts[static_cast(i)]; + if (!all_digits(p) && !(p.size() > 1 && (p[0] == '-' || p[0] == '+') && all_digits(p.substr(1)))) { if (ok) *ok = false; return nullptr; } + v[i] = std::strtol(p.c_str(), nullptr, 10); + } + long code = v[0]; + int x = static_cast(v[1]), y = static_cast(v[2]); + std::vector mods; + if (code & 4) mods.push_back("shift"); + if (code & 8) mods.push_back("alt"); + if (code & 16) mods.push_back("ctrl"); + long button = code & ~28L; + if (ok) *ok = true; + if (button >= 64 && button <= 67) { + static const char* dirs[] = {"up", "down", "left", "right"}; + return std::make_unique(x - 1, y - 1, dirs[button - 64], mods); + } + if (button == 3) return std::make_unique(x - 1, y - 1, 0, mods, false); + if ((button == 0 || button == 1 || button == 2) && pressed) return std::make_unique(x - 1, y - 1, static_cast(button), mods, true); + if (button == 0 || button == 1 || button == 2) return std::make_unique(x - 1, y - 1, static_cast(button), mods, false); + return nullptr; +} + +EventP TuiInput::parse_mouse_legacy(const std::string& d) { + long code = static_cast(d[0]) - 32, x = static_cast(d[1]) - 32, y = static_cast(d[2]) - 32; + std::vector mods; + if (code & 4) mods.push_back("shift"); + if (code & 8) mods.push_back("alt"); + if (code & 16) mods.push_back("ctrl"); + long button = code & ~28L; + if (button == 64 || button == 65) return std::make_unique(static_cast(x - 1), static_cast(y - 1), button == 64 ? "up" : "down", mods); + if (button == 3) return std::make_unique(static_cast(x - 1), static_cast(y - 1), 0, mods, false); + if (button == 0 || button == 1 || button == 2) return std::make_unique(static_cast(x - 1), static_cast(y - 1), static_cast(button), mods, true); + return nullptr; +} + +TuiInput::Parsed TuiInput::parse_kitty(const std::string& body, const std::string& rest) { + auto nothing = [&]() { return make_event(nullptr, rest); }; + if (body.empty() || std::string("?>!<=").find(body[0]) != std::string::npos) return nothing(); + for (char c : body) if (!((c >= '0' && c <= '9') || c == ':' || c == ';')) return nothing(); + auto parts = split(body, ';'); + std::string first = split(parts[0], ':')[0]; + long number = first.empty() ? 0 : std::strtol(first.c_str(), nullptr, 10); + long mods = 1, etype = 1; + if (parts.size() > 1 && !parts[1].empty()) { + auto fields = split(parts[1], ':'); + mods = !fields[0].empty() ? tui_int(fields[0], 1) : 1; + if (fields.size() > 1 && !fields[1].empty()) etype = tui_int(fields[1], 1); + } + if (etype == 3) return nothing(); + UStr text; + for (size_t pi = 2; pi < parts.size(); pi++) { + for (auto& piece : split(parts[pi], ':')) { + if (all_digits(piece)) { + unsigned long long point = std::strtoull(piece.c_str(), nullptr, 10); + if (piece.size() < 12 && point <= 0x10FFFF) text.push_back(static_cast(point)); + } + } + } + auto it = kitty_mods().find(number); + if (it != kitty_mods().end()) { + if (etype == 2) return nothing(); + return make_event(std::make_unique(it->second, UStr(), std::vector{it->second}), rest); + } + auto nm = kitty_named().find(number); + if (nm != kitty_named().end()) { + if (etype == 2) return nothing(); + auto names = modifiers(static_cast(((mods - 1) & 7) + 1)); + std::string key = names.empty() ? nm->second : join_key(names, nm->second); + return make_event(std::make_unique(key, UStr(), names), rest); + } + auto pd = kitty_pad().find(number); + if (pd != kitty_pad().end()) return make_event(std::make_unique(pd->second, TuiStr::u(pd->second)), rest); + if (number >= 1 && number <= 26) { + std::string letter(1, static_cast(number + 96)); + return make_event(std::make_unique("ctrl+" + letter, char_text(static_cast(number)), std::vector{"ctrl"}), rest); + } + if (number == 0) { + if (text.empty()) return nothing(); + } else if ((number >= 57344 && number <= 63743) || !(number >= 32 && number <= 0x10FFFF) || (number >= 0xD800 && number <= 0xDFFF)) { + return nothing(); + } + if (!text.empty()) { + std::string key = text == U" " ? "space" : TuiStr::s(text); + return make_event(std::make_unique(key, text), rest); + } + char32_t ch = static_cast(number); + auto names = modifiers(static_cast(((mods - 1) & 7) + 1)); + if (names == std::vector{"shift"} && ch >= 'a' && ch <= 'z') { + char32_t up = ch - 32; + return make_event(std::make_unique(TuiStr::s(up), char_text(up)), rest); + } + if (names == std::vector{"shift"} && kitty_us_shift().count(ch)) { + char32_t sh = kitty_us_shift().at(ch); + return make_event(std::make_unique(TuiStr::s(sh), char_text(sh)), rest); + } + if (names == std::vector{"ctrl"} && ch >= 'a' && ch <= 'z') { + return make_event(std::make_unique("ctrl+" + TuiStr::s(ch), char_text(ch - 96), names), rest); + } + if (!names.empty()) return make_event(std::make_unique(join_key(names, TuiStr::s(ch)), char_text(ch), names), rest); + std::string key = ch == U' ' ? "space" : TuiStr::s(ch); + return make_event(std::make_unique(key, char_text(ch)), rest); +} + +bool TuiInput::paste_head(const std::string& buf) { + std::string open = PASTE_OPEN; + std::string head = buf.substr(0, open.size()); + return head == open.substr(0, head.size()); +} + +TuiInput::Parsed TuiInput::parse_paste(const std::string& buf, const std::string& rest) { + size_t close = rest.find(PASTE_CLOSE); + if (close == std::string::npos) { + if (buf.size() > PASTE_CAP) return make_event(nullptr, ""); + return need_more(buf); + } + size_t after = close + std::string(PASTE_CLOSE).size(); + if (close > PASTE_CAP) return make_event(nullptr, rest.substr(after)); + return make_event(std::make_unique(TuiStr::u(rest.substr(0, close))), rest.substr(after)); +} + +TuiInput::Parsed TuiInput::parse_one(const std::string& buf, bool final) { + if (buf.empty()) return make_event(nullptr, buf); + unsigned char b0 = static_cast(buf[0]); + if (b0 != 0x1B) { + if (b0 == 0x0D || b0 == 0x0A) { + if (b0 == 0x0D && buf.size() > 1 && buf[1] == 0x0A) return make_event(std::make_unique("enter", U"\n"), buf.substr(2)); + if (b0 == 0x0D && buf.size() == 1 && !final) return need_more(buf); + return make_event(std::make_unique("enter", U"\n"), buf.substr(1)); + } + if (b0 == 0x09) return make_event(std::make_unique("tab", U"\t"), buf.substr(1)); + if (b0 == 0x7F) return make_event(std::make_unique("backspace", U"\x7f"), buf.substr(1)); + if (b0 < 0x20) { + std::string letter(1, b0 != 0 ? static_cast(b0 + 0x60) : '@'); + return make_event(std::make_unique("ctrl+" + letter, char_text(b0)), buf.substr(1)); + } + Utf8Head h = utf8_head(buf); + if (h.need_more) return need_more(buf); + if (h.ch == U' ') return make_event(std::make_unique("space", char_text(h.ch)), buf.substr(h.length)); + return make_event(std::make_unique(TuiStr::s(h.ch), char_text(h.ch)), buf.substr(h.length)); + } + if (buf.size() == 1) return final ? escape(buf.substr(1)) : need_more(buf); + char second = buf[1]; + if (second == 0x1B) return escape(buf.substr(1)); + if (second == '[') { + if (buf.size() >= 3 && buf[2] == 'M') { + if (buf.size() < 6) return need_more(buf); + EventP ev = parse_mouse_legacy(buf.substr(3, 3)); + return make_event(std::move(ev), buf.substr(6)); + } + if (buf.size() >= 3 && buf[2] == '<') { + long end = -1; + for (size_t pos = 3; pos < std::min(buf.size(), 32); pos++) { + if (buf[pos] == 'M' || buf[pos] == 'm') { end = static_cast(pos); break; } + } + if (end < 0) return need_more(buf); + std::string text = buf.substr(2, static_cast(end) + 1 - 2); + for (char c : text) if (static_cast(c) >= 0x80) return make_event(nullptr, buf.substr(static_cast(end) + 1)); + bool ok = false; + EventP ev = parse_mouse_sgr(text, &ok); + return make_event(std::move(ev), buf.substr(static_cast(end) + 1)); + } + long end = -1; + for (size_t pos = 2; pos < std::min(buf.size(), 64); pos++) { + unsigned char c = static_cast(buf[pos]); + if (c >= 0x40 && c <= 0x7E) { end = static_cast(pos); break; } + } + if (end < 0) return need_more(buf); + std::string body = buf.substr(2, static_cast(end) - 2); + for (char c : body) if (static_cast(c) >= 0x80) return make_event(nullptr, buf.substr(static_cast(end) + 1)); + char final_char = buf[static_cast(end)]; + std::string rest = buf.substr(static_cast(end) + 1); + if (final_char == '~' && body == "200") return parse_paste(buf, rest); + if (final_char == 'u') return parse_kitty(body, rest); + if (final_char == 'y' && !body.empty() && body[0] == '?' && body.back() == '$') { + std::string inner = body.substr(1, body.size() - 2); + size_t semi = inner.find(';'); + std::string mode = semi == std::string::npos ? inner : inner.substr(0, semi); + std::string state = semi == std::string::npos ? "" : inner.substr(semi + 1); + return make_event(std::make_unique(static_cast(tui_int(mode, -1)), static_cast(tui_int(state, 0))), rest); + } + if (final_char == '~' && body.find(';') == std::string::npos) { + auto it = tilde_keys().find(body); + if (it != tilde_keys().end()) return make_event(std::make_unique(it->second, UStr()), rest); + return make_event(nullptr, rest); + } + if (body.find(';') != std::string::npos) { + size_t semi = body.find(';'); + std::string base = body.substr(0, semi), mod = body.substr(semi + 1); + auto mods = modifiers(static_cast(tui_int(mod, 1))); + std::string base_key; + bool have = false; + if (final_char == '~') { + auto it = tilde_keys().find(base); + if (it != tilde_keys().end()) { base_key = it->second; have = true; } + } else { + auto it = csi_keys().find(std::string(1, final_char)); + if (it != csi_keys().end()) { base_key = it->second; have = true; } + } + if (have) return make_event(std::make_unique(mods.empty() ? base_key : join_key(mods, base_key), UStr(), mods), rest); + return make_event(nullptr, rest); + } + auto it = csi_keys().find(std::string(1, final_char)); + if (it != csi_keys().end() && body.empty()) return make_event(std::make_unique(it->second, UStr()), rest); + return make_event(nullptr, rest); + } + if (second == 'O') { + if (buf.size() < 3) return need_more(buf); + auto it = ss3_keys().find(buf[2]); + if (it != ss3_keys().end()) return make_event(std::make_unique(it->second, UStr()), buf.substr(3)); + return make_event(nullptr, buf.substr(3)); + } + if (second == ']' || second == 'P' || second == 'X' || second == '^' || second == '_') return make_event(nullptr, buf.substr(2)); + Utf8Head h = utf8_head(buf.substr(1)); + if (h.need_more) return need_more(buf); + if (h.ch == U'\r') return make_event(std::make_unique("alt+enter", U"\n", std::vector{"alt"}), buf.substr(1 + h.length)); + return make_event(std::make_unique("alt+" + TuiStr::s(h.ch), char_text(h.ch)), buf.substr(1 + h.length)); +} + +std::vector TuiInput::feed(const std::string& data) { + if (!data.empty()) buf += data; + return drain(false); +} + +std::vector TuiInput::flush() { + std::vector events = drain(true); + while (!buf.empty() && buf[0] == 0x1B && !paste_head(buf)) { + size_t before = buf.size(); + for (auto& e : drain(true)) events.push_back(std::move(e)); + if (buf.size() == before) { + events.push_back(std::make_unique("escape", U"\x1b")); + buf = buf.substr(1); + for (auto& e : drain(true)) events.push_back(std::move(e)); + } + } + return events; +} + +std::vector TuiInput::drain(bool final) { + std::vector events; + while (!buf.empty()) { + Parsed parsed = parse_one(buf, final); + if (parsed.kind == Parsed::NeedMore) break; + if (parsed.kind == Parsed::Escape) events.push_back(std::make_unique("escape", U"\x1b")); + else if (parsed.kind == Parsed::Event) events.push_back(std::move(parsed.event)); + if (parsed.rest == buf) break; + buf = std::move(parsed.rest); + } + return events; +} + +} // namespace detz::tui diff --git a/taicpp/src/tui/TuiInput.h b/taicpp/src/tui/TuiInput.h new file mode 100644 index 0000000..8e9f6b8 --- /dev/null +++ b/taicpp/src/tui/TuiInput.h @@ -0,0 +1,50 @@ +#pragma once +// TuiInput: incremental stdin parser (tai.py TuiInput plus the tui_parse_* +// functions and key tables). feed() bytes in, events out; flush() turns a +// trailing lone ESC into the Escape key once input idles. +#include "TuiEvent.h" +#include +#include +#include +#include +namespace detz::tui { + +using EventP = std::unique_ptr; + +class TuiInput { +public: + // result of one parse step (python: event | "need_more" | "escape" | None, rest) + struct Parsed { + enum Kind { Nothing, Event, NeedMore, Escape } kind{Nothing}; + EventP event; + std::string rest; + }; + + std::vector feed(const std::string& data); + std::vector flush(); + std::string buf; + + static constexpr const char* PASTE_OPEN = "\x1b[200~"; + static constexpr const char* PASTE_CLOSE = "\x1b[201~"; + static constexpr size_t PASTE_CAP = 1u << 20; + + static Parsed parse_one(const std::string& buf, bool final); + static Parsed parse_paste(const std::string& buf, const std::string& rest); + static Parsed parse_kitty(const std::string& body, const std::string& rest); + static EventP parse_mouse_sgr(const std::string& text, bool* ok); + static EventP parse_mouse_legacy(const std::string& data3); + static bool paste_head(const std::string& buf); + static std::vector modifiers(int code); + // decode one char from head of buf: returns (char, length, need_more) + struct Utf8Head { char32_t ch; size_t length; bool need_more; }; + static Utf8Head utf8_head(const std::string& buf); + + static const std::map& csi_keys(); + static const std::map& tilde_keys(); + static const std::map& ss3_keys(); + +private: + std::vector drain(bool final); +}; + +} // namespace detz::tui diff --git a/taicpp/src/tui/TuiLog.cpp b/taicpp/src/tui/TuiLog.cpp new file mode 100644 index 0000000..f18fb85 --- /dev/null +++ b/taicpp/src/tui/TuiLog.cpp @@ -0,0 +1,162 @@ +#include "TuiLog.h" +#include "TuiApp.h" +#include +namespace detz::tui { + +TuiLog::TuiLog(TuiPanelOpts opts) : TuiPanel([&] { + if (!opts.scrollable) opts.scrollable = true; + return opts; +}()) {} + +void TuiLog::set_lines(std::vector v) { + lines = std::move(v); + touch(); +} + +std::pair TuiLog::measure() { + int width = 0; + size_t n = lines.size(); + size_t from = n > 500 ? n - 500 : 0; + for (size_t i = from; i < n; i++) width = std::max(width, static_cast(lines[i].text.size())); + return {width, std::max(1, static_cast(lines.size()))}; +} + +void TuiLog::push(const UStr& text, std::shared_ptr spans) { + bool pinned = scroll_y() >= std::max(0, static_cast(lines.size()) - content_h()); + lines.push_back(TuiLogLine{text, std::move(spans)}); + touch(); // observed list append + if (pinned) set_scroll_y(std::max(0, static_cast(lines.size()) - content_h())); + if (app) touch(); +} + +void TuiLog::append(const UStr& text, StyleP st) { + push(text, std::make_shared(SpanRow{TuiSpan(text, st ? st : style())})); +} + +void TuiLog::append_spans(const SpanRow& spans_in) { + SpanRow spans = spans_in; + if (spans.empty()) spans.push_back(TuiSpan(UStr(), style())); + UStr text; + for (auto& s : spans) text += s.text; + push(text, std::make_shared(std::move(spans))); +} + +SpanRows TuiLog::content_rows() { + SpanRows rows; + rows.reserve(lines.size()); + for (auto& l : lines) rows.push_back(*l.spans); + return rows; +} + +TuiCellRows TuiLog::paint_cells(TuiCompositor& compositor) { + if (!plain_paint() || aw < chrome_w() + 2) return TuiPanel::paint_cells(compositor); + return log_cells(compositor); +} + +TuiCellRows TuiLog::log_cells(TuiCompositor& compositor) { + int width = content_w(), height = content_h(); + int pad_top = padding()[0], pad_right = padding()[1], pad_bottom = padding()[2], pad_left = padding()[3]; + const TuiStyleKey& key = style()->key(); + TuiCell blank = compositor.cell(U' ', key); + int inner_w = width + pad_left + pad_right; + TuiCellRow edge_left, edge_right; + std::optional top, bottom; + if (border()) { + SpanRows framed = add_border(SpanRows{SpanRow{}}, inner_w); + TuiCellRows tb = compositor.cells_from(SpanRows{framed.front(), framed.back()}, aw); + top = tb[0]; + bottom = tb[1]; + TuiCellRow bars = compositor.cells_from(SpanRows{framed[1]}, 2)[0]; + edge_left = {bars[0]}; + edge_right = {bars[1]}; + } + TuiCellRow head = edge_left; + head.insert(head.end(), static_cast(pad_left), blank); + TuiCellRow tail(static_cast(pad_right), blank); + tail.insert(tail.end(), edge_right.begin(), edge_right.end()); + TuiCellRow pad_row = edge_left; + pad_row.insert(pad_row.end(), static_cast(inner_w), blank); + pad_row.insert(pad_row.end(), edge_right.begin(), edge_right.end()); + TuiCellRow empty(static_cast(width), blank); + TuiCellRows rows; + if (top) rows.push_back(*top); + for (int i = 0; i < pad_top; i++) rows.push_back(pad_row); + size_t total = lines.size(); + long scroll_x = scroll_x_; + long first = scroll_y_; + std::vector found; + std::vector>> missing; // (index into found, spans) + for (long index = first; index < std::min(first + height, static_cast(total)); index++) { + const std::shared_ptr& spans = lines[static_cast(index)].spans; + auto it = row_memo_.find(spans.get()); + if (it != row_memo_.end() && it->second.spans.get() == spans.get() && it->second.scroll_x == scroll_x && it->second.width == width && + it->second.key == key) { + found.push_back(&it->second.cells); + } else { + found.push_back(nullptr); + missing.emplace_back(found.size() - 1, spans); + } + } + std::vector converted_storage; + if (!missing.empty()) { + if (row_memo_.size() + missing.size() > TUI_ROW_MEMO) { + row_memo_.clear(); + // pointers into the cleared memo are gone: re-resolve found entries from scratch + for (auto& f : found) f = nullptr; + missing.clear(); + for (size_t i = 0; i < found.size(); i++) missing.emplace_back(i, lines[static_cast(first) + i].spans); + } + SpanRows slices; + for (auto& m : missing) slices.push_back(TuiSpans::slice(*m.second, static_cast(scroll_x), width, style())); + TuiCellRows conv = compositor.cells_from(slices, width); + for (size_t k = 0; k < missing.size(); k++) { + MemoEntry entry{missing[k].second, scroll_x, width, key, conv[k]}; + auto res = row_memo_.insert_or_assign(missing[k].second.get(), std::move(entry)); + found[missing[k].first] = &res.first->second.cells; + } + } + for (const TuiCellRow* cells : found) { + TuiCellRow row = head; + row.insert(row.end(), cells->begin(), cells->end()); + row.insert(row.end(), tail.begin(), tail.end()); + rows.push_back(std::move(row)); + } + for (size_t i = 0; i < static_cast(std::max(0, height - static_cast(found.size()))); i++) { + TuiCellRow row = head; + row.insert(row.end(), empty.begin(), empty.end()); + row.insert(row.end(), tail.begin(), tail.end()); + rows.push_back(std::move(row)); + } + for (int i = 0; i < pad_bottom; i++) rows.push_back(pad_row); + if (bottom) rows.push_back(*bottom); + TuiCellRow full(static_cast(aw), blank); + while (static_cast(rows.size()) < ah) rows.push_back(full); + rows.resize(static_cast(ah)); + return rows; +} + +void TuiLog::on_scroll(TuiScroll& event) { + event.stop(); + if (event.direction == "up") scroll_by(0, -3); + else if (event.direction == "down") scroll_by(0, 3); +} + +void TuiLog::on_key(TuiKey& event) { + if (event.key == "pageup") { + event.stop(); + scroll_by(0, -std::max(1, content_h())); + } else if (event.key == "pagedown") { + event.stop(); + scroll_by(0, std::max(1, content_h())); + } else if (event.key == "home") { + event.stop(); + set_scroll_y(0); + if (app) touch(); + } else if (event.key == "end") { + event.stop(); + set_scroll_y(std::max(0, static_cast(lines.size()) - content_h())); + if (app) touch(); + } +} + +} // namespace detz::tui diff --git a/taicpp/src/tui/TuiLog.h b/taicpp/src/tui/TuiLog.h new file mode 100644 index 0000000..294682d --- /dev/null +++ b/taicpp/src/tui/TuiLog.h @@ -0,0 +1,49 @@ +#pragma once +// TuiLog: virtualized scrollback view (tai.py TuiLog). Wheel scrolls; +// appending follows the tail only while already pinned there. Lines are +// (text, spans) pairs: append() takes plain text plus one style, +// append_spans() takes pre-built multi-style runs (chat bubbles). +// +// paint_cells keeps a line's converted cell row between paints (looked up by +// the identity of its span list), so scrolling or repainting after a neighbor +// changed converts only lines it has not seen. Lines are append-only span +// lists, which is what makes that safe. +#include "TuiCompositor.h" +#include "TuiPanel.h" +#include +namespace detz::tui { + +constexpr size_t TUI_ROW_MEMO = 4096; + +struct TuiLogLine { + UStr text; + std::shared_ptr spans; +}; + +class TuiLog : public TuiPanel { +public: + explicit TuiLog(TuiPanelOpts opts = {}); + std::vector lines; // python observed list: external code calls touch()/set_lines() + void set_lines(std::vector v); + bool shifts() const override { return true; } + std::pair measure() override; + void append(const UStr& text, StyleP style = nullptr); + void append_spans(const SpanRow& spans); + SpanRows content_rows() override; + TuiCellRows paint_cells(TuiCompositor& compositor) override; + void on_scroll(TuiScroll& event) override; + void on_key(TuiKey& event) override; +protected: + virtual bool plain_paint() const { return true; } // false when a subclass replaced paint/content_rows + TuiCellRows log_cells(TuiCompositor& compositor); +private: + void push(const UStr& text, std::shared_ptr spans); + struct MemoEntry { + std::shared_ptr spans; + long scroll_x; int width; TuiStyleKey key; + TuiCellRow cells; + }; + std::unordered_map row_memo_; +}; + +} // namespace detz::tui diff --git a/taicpp/src/tui/TuiOptions.cpp b/taicpp/src/tui/TuiOptions.cpp new file mode 100644 index 0000000..beb1237 --- /dev/null +++ b/taicpp/src/tui/TuiOptions.cpp @@ -0,0 +1,114 @@ +#include "TuiOptions.h" +#include "TuiApp.h" +#include +namespace detz::tui { + +TuiOptions::TuiOptions(const std::vector& opts_in, long selected, bool multi, TuiHandler on_select, TuiPanelOpts opts) + : TuiPanel([&] { + if (!opts.focusable) opts.focusable = true; + if (!opts.scrollable) opts.scrollable = true; + return opts; + }()), + multi_(multi) { + for (auto& item : opts_in) options.push_back(norm_option(item)); + if (on_select) set_handler("select", std::move(on_select)); + if (!options.empty()) selected_ = std::max(0L, std::min(selected, static_cast(options.size()) - 1)); +} + +TuiOption TuiOptions::norm_option(const TuiValue& item) { + if (item.is_list() && item.size() >= 2) return {TuiStr::u(item.at(0).str()), item.at(1)}; + if (item.is_dict()) return {TuiStr::u(item.get("label", TuiValue("")).str()), item.get("value")}; + return {TuiStr::u(item.str()), item}; +} +TuiOption TuiOptions::norm_option(const std::string& label) { return {TuiStr::u(label), TuiValue(label)}; } +TuiOption TuiOptions::norm_option(const UStr& label, const TuiValue& value) { return {label, value}; } + +void TuiOptions::set_selected(long v) { bool same = selected_ == v; selected_ = v; if (app && !same) touch(); } + +std::pair TuiOptions::measure() { + int width = 4; + for (auto& o : options) width = std::max(width, static_cast(o.label.size()) + 4); + return {width, std::max(1, static_cast(options.size()))}; +} + +SpanRows TuiOptions::content_rows() { + int width = content_w(), height = content_h(); + follow_selection(height); + SpanRows rows; + bool focused = app && app->focused == this; + for (int pos = 0; pos < height; pos++) { + long index = scroll_y() + pos; + if (0 <= index && index < static_cast(options.size())) { + const UStr& label = options[static_cast(index)].label; + UStr marker = U">"; + if (multi_) marker = chosen.count(index) ? U"[x]" : U"[ ]"; + else if (index != selected_) marker = U" "; + UStr text = TuiStr::ljust(TuiStr::head(U" " + marker + U" " + label, width), width); + StyleP st; + if (index == selected_ && enabled()) st = style()->merged(TuiStyle(TuiColor(), TuiColor(), focused, false, false, true)); + else st = style(); + rows.push_back(SpanRow{TuiSpan(text, st)}); + } else { + rows.push_back(SpanRow{TuiSpan(UStr(static_cast(width), U' '), style())}); + } + } + return rows; +} + +void TuiOptions::follow_selection(int height) { + if (selected_ < scroll_y()) set_scroll_y(static_cast(selected_)); + else if (selected_ >= scroll_y() + std::max(1, height)) set_scroll_y(static_cast(selected_) - std::max(1, height) + 1); +} + +void TuiOptions::move(long delta) { + if (options.empty() || !enabled()) return; + set_selected(std::max(0L, std::min(static_cast(options.size()) - 1, selected_ + delta))); + if (app) touch(); +} + +void TuiOptions::choose(long index, bool has_index) { + if (options.empty() || !enabled()) return; + if (has_index) set_selected(std::max(0L, std::min(static_cast(options.size()) - 1, index))); + if (multi_) { + if (chosen.count(selected_)) chosen.erase(selected_); + else chosen.insert(selected_); + } + const TuiOption& o = options[static_cast(selected_)]; + TuiSelect ev; + ev.panel = this; + ev.index = selected_; + ev.option = TuiStr::s(o.label); + ev.value = o.value; + emit(ev); + if (app) touch(); +} + +void TuiOptions::on_key(TuiKey& event) { + if (!enabled()) return; + const std::string& k = event.key; + if (k == "up") { event.stop(); move(-1); } + else if (k == "down") { event.stop(); move(1); } + else if (k == "home") { event.stop(); move(-static_cast(options.size())); } + else if (k == "end") { event.stop(); move(static_cast(options.size())); } + else if (k == "pageup") { event.stop(); move(-std::max(1, content_h())); } + else if (k == "pagedown") { event.stop(); move(std::max(1, content_h())); } + else if (k == "enter" || k == "space") { event.stop(); choose(); } +} + +void TuiOptions::on_click(TuiClick& event) { + if (!enabled() || !event.pressed) return; + event.stop(); + focus(); + int pad_top = padding()[0] + (border() ? 1 : 0); + long index = scroll_y() + (event.y - ay - pad_top); + if (0 <= index && index < static_cast(options.size())) choose(index, true); +} + +void TuiOptions::on_scroll(TuiScroll& event) { + if (!enabled()) return; + event.stop(); + if (event.direction == "up") move(-1); + else if (event.direction == "down") move(1); +} + +} // namespace detz::tui diff --git a/taicpp/src/tui/TuiOptions.h b/taicpp/src/tui/TuiOptions.h new file mode 100644 index 0000000..76ddd36 --- /dev/null +++ b/taicpp/src/tui/TuiOptions.h @@ -0,0 +1,41 @@ +#pragma once +// TuiOptions: virtualized option list; arrows/mouse/wheel, Enter selects +// (tai.py TuiOptions). on_select is the Select-event handler and receives the +// event (index, option, value on it); the event also bubbles. +#include "TuiPanel.h" +#include "TuiValue.h" +#include +namespace detz::tui { + +struct TuiOption { + UStr label; + TuiValue value; +}; + +class TuiOptions : public TuiPanel { +public: + explicit TuiOptions(const std::vector& options = {}, long selected = 0, bool multi = false, + TuiHandler on_select = nullptr, TuiPanelOpts opts = {}); + static TuiOption norm_option(const TuiValue& item); + static TuiOption norm_option(const std::string& label); // plain str: (str, str) + static TuiOption norm_option(const UStr& label, const TuiValue& value); // (label, value) + + std::vector options; // mutate then call touch() (python's observed list did it) + long selected() const { return selected_; } + void set_selected(long v); + bool multi() const { return multi_; } + std::set chosen; // observed set in python: mutate then touch() + std::pair measure() override; + SpanRows content_rows() override; + virtual void follow_selection(int height); + void move(long delta); + void choose(long index = -1, bool has_index = false); + void on_key(TuiKey& event) override; + void on_click(TuiClick& event) override; + void on_scroll(TuiScroll& event) override; +protected: + long selected_{0}; + bool multi_; +}; + +} // namespace detz::tui diff --git a/taicpp/src/tui/TuiPanel.cpp b/taicpp/src/tui/TuiPanel.cpp new file mode 100644 index 0000000..dbc1cb5 --- /dev/null +++ b/taicpp/src/tui/TuiPanel.cpp @@ -0,0 +1,767 @@ +#include "TuiPanel.h" +#include "TuiApp.h" +#include "TuiCompositor.h" +#include +#include +#include +namespace detz::tui { + +std::unordered_map>* TuiPanel::measure_memo = nullptr; +bool TuiPanel::stale = false; +std::vector* TuiPanel::arranged = nullptr; +long TuiPanel::seq_counter = 0; + +namespace { +const UStr kTL = U"┌", kTR = U"┐", kBL = U"└", kBR = U"┘"; +const UStr kRTL = U"╭", kRTR = U"╮", kRBL = U"╰", kRBR = U"╯"; +} // namespace + +std::array TuiPanel::norm_padding(int all) { return {{all, all, all, all}}; } +std::array TuiPanel::norm_padding(int v, int h) { return {{v, h, v, h}}; } +std::array TuiPanel::norm_padding(int t, int r, int b, int l) { return {{t, r, b, l}}; } + +TuiPanel::TuiPanel(TuiPanelOpts o) { + seq = ++seq_counter; + id = o.id; + title = o.title ? *o.title : UStr(); + x_ = o.x ? *o.x : TuiSpec(); + y_ = o.y ? *o.y : TuiSpec(); + w_ = o.w ? *o.w : TuiSpec("auto"); + h_ = o.h ? *o.h : TuiSpec("auto"); + flow_ = o.flow ? *o.flow : std::string("rows"); + dock_ = o.dock; + layer_ = o.layer ? *o.layer : std::string(); + z_ = o.z ? *o.z : 0; + offset_ = o.offset ? *o.offset : std::pair{0, 0}; + align_ = o.align; + valign_ = o.valign; + padding_ = o.padding; + gap_ = std::max(0, o.gap); + border_ = o.border ? *o.border : false; + border_style_ = o.border_style ? o.border_style : mk_style("white"); + rounded_ = o.rounded ? *o.rounded : false; + focus_border_style_ = o.focus_border_style; + style_ = o.style ? o.style : std::make_shared(); + title_style_ = o.title_style ? o.title_style : mk_style(TuiColor(), TuiColor(), true); + visible_ = o.visible; + focusable_ = o.focusable ? *o.focusable : false; + enabled_ = o.enabled; + modal_ = o.modal ? *o.modal : false; + scrollable_ = o.scrollable ? *o.scrollable : false; + adopt_style(border_style_); + adopt_style(focus_border_style_); + adopt_style(style_); + adopt_style(title_style_); +} + +TuiPanel::~TuiPanel() { + for (auto& weak : adopted_) { + if (auto s = weak.lock()) s->disown(this); + } +} + +void TuiPanel::adopt_style(const StyleP& s) { + if (!s) return; + s->adopt(this); + adopted_.push_back(s); +} + +// ---- watched attribute writes --------------------------------------------- + +static bool eq_style(const StyleP& a, const StyleP& b) { return a && b && a->key() == b->key(); } + +void TuiPanel::set_x(const TuiSpec& v) { bool same = x_ == v; x_ = v; if (app && !same) { app->relayout(this); touch(); } } +void TuiPanel::set_y(const TuiSpec& v) { bool same = y_ == v; y_ = v; if (app && !same) { app->relayout(this); touch(); } } +void TuiPanel::set_w(const TuiSpec& v) { bool same = w_ == v; w_ = v; if (app && !same) { app->relayout(this); touch(); } } +void TuiPanel::set_h(const TuiSpec& v) { bool same = h_ == v; h_ = v; if (app && !same) { app->relayout(this); touch(); } } +void TuiPanel::set_flow(const std::string& v) { bool same = flow_ == v; flow_ = v; if (app && !same) { app->relayout(this); touch(); } } +void TuiPanel::set_dock(const std::string& v) { bool same = dock_ == v; dock_ = v; if (app && !same) { app->relayout(this); touch(); } } +void TuiPanel::set_offset(std::pair v) { bool same = offset_ == v; offset_ = v; if (app && !same) { app->relayout(this); touch(); } } +void TuiPanel::set_align(const std::string& v) { bool same = align_ == v; align_ = v; if (app && !same) { app->relayout(this); touch(); } } +void TuiPanel::set_valign(const std::string& v) { bool same = valign_ == v; valign_ = v; if (app && !same) { app->relayout(this); touch(); } } +void TuiPanel::set_padding(const std::array& v) { bool same = padding_ == v; padding_ = v; if (app && !same) { app->relayout(this); touch(); } } +void TuiPanel::set_gap(int v) { v = std::max(0, v); bool same = gap_ == v; gap_ = v; if (app && !same) { app->relayout(this); touch(); } } +void TuiPanel::set_border(bool v) { bool same = border_ == v; border_ = v; if (app && !same) { app->relayout(this); touch(); } } +void TuiPanel::set_rounded(bool v) { bool same = rounded_ == v; rounded_ = v; if (app && !same) touch(); } +void TuiPanel::set_layer(const std::string& v) { bool same = layer_ == v; layer_ = v; if (app && !same) app->restack(); } +void TuiPanel::set_z(int v) { bool same = z_ == v; z_ = v; if (app && !same) app->restack(); } +void TuiPanel::set_visible(bool v) { + bool same = visible_ == v; + visible_ = v; + if (app && !same) { + forget_measure(); + app->relayout(this, false, true); + } +} +void TuiPanel::set_focusable(bool v) { bool same = focusable_ == v; focusable_ = v; if (app && !same) touch(); } +void TuiPanel::set_enabled(bool v) { bool same = enabled_ == v; enabled_ = v; if (app && !same) touch(); } +void TuiPanel::set_modal(bool v) { bool same = modal_ == v; modal_ = v; if (app && !same) touch(); } +void TuiPanel::set_scrollable(bool v) { bool same = scrollable_ == v; scrollable_ = v; if (app && !same) touch(); } +void TuiPanel::set_title(const UStr& v) { bool same = title == v; title = v; if (app && !same) touch(); } + +bool TuiPanel::scroll_in_range(bool horizontal, int value) const { + if (!measure_seen_) return false; + if (horizontal) return 0 <= value && value <= std::max(0, measure_seen_->first - content_w()); + return 0 <= value && value <= std::max(0, measure_seen_->second - content_h()); +} + +void TuiPanel::set_scroll_x(int v) { + int old = scroll_x_; + scroll_x_ = v; + if (!app || old == v) return; + if (!scroll_in_range(true, v)) app->relayout(this, false, false, true); + touch(); +} +void TuiPanel::set_scroll_y(int v) { + int old = scroll_y_; + scroll_y_ = v; + if (!app) return; + if (shifts()) shift_ += v - old; + if (old == v) return; + if (!scroll_in_range(false, v)) app->relayout(this, false, false, true); + touch(); +} + +void TuiPanel::set_style(const StyleP& v) { + bool same = eq_style(style_, v); + style_ = v; + adopt_style(v); + if (app && !same) touch(); +} +void TuiPanel::set_border_style(const StyleP& v) { + bool same = eq_style(border_style_, v); + border_style_ = v; + adopt_style(v); + if (app && !same) touch(); +} +void TuiPanel::set_title_style(const StyleP& v) { + bool same = eq_style(title_style_, v); + title_style_ = v; + adopt_style(v); + if (app && !same) touch(); +} +void TuiPanel::set_focus_border_style(const StyleP& v) { + bool same = eq_style(focus_border_style_, v); + focus_border_style_ = v; + adopt_style(v); + if (app && !same) touch(); +} + +void TuiPanel::set_handler(const std::string& name, TuiHandler fn) { + handlers_[name] = std::move(fn); + if (app && name == "tick") app->tree_changed(); +} +void TuiPanel::clear_handler(const std::string& name) { + handlers_.erase(name); + if (app && name == "tick") app->tree_changed(); +} +bool TuiPanel::has_handler(const std::string& name) const { return handlers_.count(name) != 0; } + +void TuiPanel::touch() { + cells_.reset(); + forget_measure(); + if (app) app->damage(this); +} + +// ---- tree ----------------------------------------------------------------- + +PanelP TuiPanel::add(PanelP child) { + child->parent = this; + children.push_back(child); + if (app) child->bind_app(app); + forget_measure(); + if (app) app->relayout(this, true, true); + return child; +} +void TuiPanel::add_all(std::initializer_list kids) { for (auto& k : kids) add(k); } + +PanelP TuiPanel::remove(const PanelP& child) { return remove(child.get()); } +PanelP TuiPanel::remove(TuiPanel* child) { + PanelP keep; + auto it = std::find_if(children.begin(), children.end(), [&](const PanelP& c) { return c.get() == child; }); + if (it != children.end()) { + keep = *it; + children.erase(it); + child->parent = nullptr; + child->unbind_app(); + } + forget_measure(); + if (app) app->relayout(this, true, true); + return keep; +} +void TuiPanel::clear() { + std::vector copy = children; + for (auto& c : copy) remove(c.get()); +} +bool TuiPanel::contains_child(const TuiPanel* child) const { + return std::any_of(children.begin(), children.end(), [&](const PanelP& c) { return c.get() == child; }); +} + +std::vector TuiPanel::walk() { + std::vector out; + std::vector stack{this}; + while (!stack.empty()) { + TuiPanel* node = stack.back(); + stack.pop_back(); + out.push_back(node); + for (auto it = node->children.rbegin(); it != node->children.rend(); ++it) stack.push_back(it->get()); + } + return out; +} +TuiPanel* TuiPanel::find(const std::string& want) { + for (TuiPanel* node : walk()) if (node->id == want) return node; + return nullptr; +} +TuiPanel* TuiPanel::root() { + TuiPanel* node = this; + while (node->parent) node = node->parent; + return node; +} +int TuiPanel::depth() const { + int d = 0; + const TuiPanel* node = this; + while (node->parent) { node = node->parent; d++; } + return d; +} +bool TuiPanel::is_shown() const { + const TuiPanel* node = this; + while (node) { + if (!node->visible_) return false; + node = node->parent; + } + return true; +} +int TuiPanel::effective_z() const { + int base = 0; + if (layer_ == "base") base = 0; else if (layer_ == "panel") base = 10; else if (layer_ == "dialog") base = 20; else if (layer_ == "toast") base = 30; + return base + z_; +} +int TuiPanel::chrome_w() const { return (border_ ? 2 : 0) + padding_[3] + padding_[1]; } +int TuiPanel::chrome_h() const { return (border_ ? 2 : 0) + padding_[0] + padding_[2]; } + +void TuiPanel::bind_app(TuiApp* a) { + app = a; + cells_.reset(); + for (auto& anim : pending_anims_) a->animations.push_back(anim); + pending_anims_.clear(); + TuiMount ev; + ev.panel = this; + emit(ev); + std::vector kids = children; + for (auto& c : kids) c->bind_app(a); +} +void TuiPanel::unbind_app() { + TuiUnmount ev; + ev.panel = this; + emit(ev); + std::vector kids = children; + for (auto& c : kids) c->unbind_app(); + app = nullptr; +} + +// ---- events --------------------------------------------------------------- + +void TuiPanel::on_event(TuiEvent& e) { + const char* n = e.name(); + if (!std::strcmp(n, "key")) on_key(static_cast(e)); + else if (!std::strcmp(n, "click")) on_click(static_cast(e)); + else if (!std::strcmp(n, "scroll")) on_scroll(static_cast(e)); + else if (!std::strcmp(n, "paste")) on_paste(static_cast(e)); + else if (!std::strcmp(n, "blur")) on_blur(static_cast(e)); + else if (!std::strcmp(n, "focus")) on_focus(static_cast(e)); + else if (!std::strcmp(n, "mount")) on_mount(static_cast(e)); + else if (!std::strcmp(n, "unmount")) on_unmount(static_cast(e)); + else if (!std::strcmp(n, "select")) on_select(static_cast(e)); + else if (!std::strcmp(n, "press")) on_press(static_cast(e)); + else if (!std::strcmp(n, "close")) on_close(static_cast(e)); + else if (!std::strcmp(n, "change")) on_change(static_cast(e)); + else if (!std::strcmp(n, "resize")) on_resize(static_cast(e)); + else if (!std::strcmp(n, "tick")) on_tick(static_cast(e)); +} + +TuiEvent& TuiPanel::dispatch(TuiEvent& event) { + TuiPanel* node = this; + bool first = true; + while (node) { + if (first || (event.bubble() && !event.stopped)) { + auto it = node->handlers_.find(event.name()); + if (it != node->handlers_.end()) { + TuiHandler fn = it->second; // copy: the handler may replace itself + if (fn) fn(event); + } else { + node->on_event(event); + } + } + if (!event.bubble() || event.stopped) break; + node = node->parent; + first = false; + } + return event; +} +TuiEvent& TuiPanel::emit(TuiEvent& event) { + event.target = this; + return dispatch(event); +} + +void TuiPanel::on_scroll(TuiScroll& event) { + if (scrollable_ && enabled_) { + event.stop(); + if (event.direction == "up") scroll_by(0, -3); + else if (event.direction == "down") scroll_by(0, 3); + else if (event.direction == "left") scroll_by(-3, 0); + else if (event.direction == "right") scroll_by(3, 0); + } +} + +// ---- geometry --------------------------------------------------------------- + +double TuiPanel::fr_weight(const TuiSpec& spec) { + if (spec.is_str() && spec.s.size() >= 2 && spec.s.compare(spec.s.size() - 2, 2, "fr") == 0) { + std::string num = spec.s.substr(0, spec.s.size() - 2); + if (num.empty()) return 1.0; + char* end = nullptr; + double v = std::strtod(num.c_str(), &end); + if (end == num.c_str() || *end != '\0') return 0.0; + return std::max(0.0, v); + } + return 0.0; +} + +std::optional TuiPanel::pct(const TuiSpec& spec, int total) { + if (spec.is_str() && !spec.s.empty() && spec.s.back() == '%') { + std::string num = spec.s.substr(0, spec.s.size() - 1); + char* end = nullptr; + double v = std::strtod(num.c_str(), &end); + if (num.empty() || end == num.c_str() || *end != '\0') return 0; + return static_cast(static_cast(total) * v / 100.0); // int(): truncate toward zero + } + return std::nullopt; +} + +int TuiPanel::fixed_len(const TuiSpec& spec, int total, int content_len) const { + if (spec.is_int()) return std::max(0L, spec.n); + if (spec.is_str()) { + auto p = pct(spec, total); + if (p) return std::max(0, *p); + if (spec.s == "auto") return std::max(0, content_len); + } + return 0; +} + +void TuiPanel::mark_pending() { + TuiPanel* node = this; + while (node) { + node->owes_arrange_ = true; + node = node->parent; + } +} + +std::pair TuiPanel::measured() { + auto* memo = measure_memo; + if (!memo || !memoize_measure()) return measure(); + auto it = memo->find(this); + if (it != memo->end()) return it->second; + std::optional> size = stale ? mcache_ : std::nullopt; + if (!size) { + size = measure(); + mcache_ = size; + } + (*memo)[this] = *size; + return *size; +} + +void TuiPanel::forget_measure() { + TuiPanel* node = this; + while (node) { + node->mcache_.reset(); + node = node->parent; + } +} + +std::pair TuiPanel::measure() { + std::vector widths, heights; + long flow_w = 0, flow_h = 0; + std::vector shown; + for (auto& c : children) if (c->visible_) shown.push_back(c.get()); + long flowing = 0; + for (TuiPanel* child : shown) { + auto cwh = child->measured(); + int outer_w = child->fixed_len(child->w_, 0, cwh.first) + child->chrome_w(); + int outer_h = child->fixed_len(child->h_, 0, cwh.second) + child->chrome_h(); + if (!child->dock_.empty() || !child->x_.is_none() || !child->y_.is_none()) { + widths.push_back(outer_w); + heights.push_back(outer_h); + } else if (flow_ == "cols") { + flow_w += outer_w; + heights.push_back(outer_h); + flowing++; + } else { + flow_h += outer_h; + widths.push_back(outer_w); + flowing++; + } + } + long gaps = static_cast(gap_) * std::max(0L, flowing - 1); + auto vmax = [](const std::vector& v, long extra) { + long m = extra; + for (int i : v) m = std::max(m, static_cast(i)); + return m; + }; + if (flow_ == "cols") return {static_cast(vmax(widths, flow_w + gaps)), static_cast(vmax(heights, 0))}; + return {static_cast(vmax(widths, 0)), static_cast(vmax(heights, flow_h + gaps))}; +} + +bool TuiPanel::content_sized() const { + return parent != nullptr && (w_.is("auto") || h_.is("auto")); +} + +TuiRect TuiPanel::intersect(const TuiRect& a, const TuiRect& b) { + return {{std::max(a[0], b[0]), std::max(a[1], b[1]), std::min(a[2], b[2]), std::min(a[3], b[3])}}; +} + +TuiRect TuiPanel::content_clip() const { + int edge = border_ ? 1 : 0; + TuiRect inner{{ax + edge + padding_[3], ay + edge + padding_[0], ax + aw - edge - padding_[1], ay + ah - edge - padding_[2]}}; + return intersect(clip, inner); +} + +void TuiPanel::arrange(int x, int y, int w, int h, TuiRect clip_in) { + std::array now{{x, y, w, h, clip_in[0], clip_in[1], clip_in[2], clip_in[3]}}; + if (stale) { + if (!owes_arrange_ && arrange_ && *arrange_ == now) return; + if (arranged) arranged->push_back(this); + } + owes_arrange_ = false; + arrange_ = now; + ax = x + offset_.first; + ay = y + offset_.second; + aw = std::max(0, w); + ah = std::max(0, h); + clip = intersect(clip_in, TuiRect{{ax, ay, ax + aw, ay + ah}}); + int edge = border_ ? 1 : 0; + std::array box{{ax + edge + padding_[3], ay + edge + padding_[0], + std::max(0, aw - edge * 2 - padding_[3] - padding_[1]), std::max(0, ah - edge * 2 - padding_[0] - padding_[2])}}; + std::vector docked, floating, flowing; + for (auto& c : children) { + if (!c->visible_) continue; + if (!c->dock_.empty()) docked.push_back(c.get()); + else if (!c->x_.is_none() || !c->y_.is_none()) floating.push_back(c.get()); + else flowing.push_back(c.get()); + } + for (TuiPanel* child : docked) place_dock(child, box); + if (flow_.empty()) { + for (TuiPanel* child : flowing) { + auto cwh = child->measured(); + int child_w = child->fixed_len(child->w_, box[2], cwh.first) + child->chrome_w(); + int child_h = child->fixed_len(child->h_, box[3], cwh.second) + child->chrome_h(); + child->arrange(box[0], box[1], child_w, child_h, content_clip()); + } + } else if (flow_ == "cols") { + place_flow(flowing, box, true); + } else { + place_flow(flowing, box, false); + } + for (TuiPanel* child : floating) place_float(child, box); + clamp_scroll(); +} + +void TuiPanel::place_dock(TuiPanel* child, std::array& box) { + auto cwh = child->measured(); + std::string side = child->dock_; + for (auto& ch : side) if (ch >= 'A' && ch <= 'Z') ch = static_cast(ch + 32); + if (side == "top" || side == "bottom") { + int child_h = child->fixed_len(child->h_, box[3], cwh.second) + child->chrome_h(); + child_h = std::min(child_h, box[3]); + int at_y = side == "top" ? box[1] : box[1] + box[3] - child_h; + child->arrange(box[0], at_y, box[2], child_h, content_clip()); + if (side == "top") box[1] += child_h; + box[3] -= child_h; + } else { + int child_w = child->fixed_len(child->w_, box[2], cwh.first) + child->chrome_w(); + child_w = std::min(child_w, box[2]); + int at_x = side != "right" ? box[0] : box[0] + box[2] - child_w; + child->arrange(at_x, box[1], child_w, box[3], content_clip()); + if (side != "right") box[0] += child_w; + box[2] -= child_w; + } +} + +void TuiPanel::place_flow(const std::vector& flowing, std::array& box, bool horizontal) { + int total = horizontal ? box[2] : box[3]; + std::vector> measures; + for (TuiPanel* c : flowing) measures.push_back(c->measured()); + std::vector> sizes; + double fr_total = 0.0; + int fixed = 0; + for (size_t i = 0; i < flowing.size(); i++) { + TuiPanel* child = flowing[i]; + const TuiSpec& spec = horizontal ? child->w_ : child->h_; + double weight = fr_weight(spec); + if (weight > 0) { + sizes.push_back(std::nullopt); + fr_total += weight; + } else { + int content = horizontal ? measures[i].first : measures[i].second; + int size = child->fixed_len(spec, total, content) + (horizontal ? child->chrome_w() : child->chrome_h()); + sizes.push_back(size); + fixed += size; + } + } + int gaps = gap_ * std::max(0, static_cast(flowing.size()) - 1); + int leftover = std::max(0, total - fixed - gaps); + std::vector final_sizes; + if (fr_total > 0) { + struct Share { int whole; double frac; }; + std::vector shares; + for (size_t i = 0; i < flowing.size(); i++) { + if (!sizes[i]) { + TuiPanel* child = flowing[i]; + double exact = leftover * fr_weight(horizontal ? child->w_ : child->h_) / fr_total; + shares.push_back({static_cast(exact), exact - static_cast(exact)}); + } + } + int given = 0; + for (auto& s : shares) given += s.whole; + std::vector order(shares.size()); + for (size_t i = 0; i < order.size(); i++) order[i] = i; + std::stable_sort(order.begin(), order.end(), [&](size_t a, size_t b) { return shares[a].frac > shares[b].frac; }); + long take = leftover - given; + if (take < 0) take = std::max(0L, static_cast(order.size()) + take); + for (size_t i = 0; i < order.size() && static_cast(i) < take; i++) shares[order[i]].whole += 1; + size_t next = 0; + for (size_t i = 0; i < flowing.size(); i++) final_sizes.push_back(sizes[i] ? *sizes[i] : shares[next++].whole); + } else { + for (size_t i = 0; i < flowing.size(); i++) final_sizes.push_back(sizes[i] ? *sizes[i] : 0); + } + int used = gaps; + for (int s : final_sizes) used += s; + int cursor = horizontal ? box[0] : box[1]; + const std::string& main_align = horizontal ? align_ : valign_; + if (main_align == "center" || main_align == "middle") cursor += std::max(0, (total - used) / 2); // floor of a non-negative + else if (main_align == "right" || main_align == "bottom") cursor += std::max(0, total - used); + for (size_t i = 0; i < flowing.size(); i++) { + TuiPanel* child = flowing[i]; + int size = final_sizes[i]; + if (horizontal) { + int child_w = size, child_h = cross_size(child, box, horizontal); + int at_x = cursor, at_y = cross_pos(child_h, box, horizontal); + cursor += size + gap_; + child->arrange(at_x, at_y, child_w, child_h, content_clip()); + } else { + int child_h = size, child_w = cross_size(child, box, horizontal); + int at_y = cursor, at_x = cross_pos(child_w, box, horizontal); + cursor += size + gap_; + child->arrange(at_x, at_y, child_w, child_h, content_clip()); + } + } +} + +int TuiPanel::cross_size(TuiPanel* child, const std::array& box, bool horizontal) { + auto cwh = child->measured(); + const TuiSpec* spec; + int total, content; + if (horizontal) { spec = &child->h_; total = box[3]; content = cwh.second; } + else { spec = &child->w_; total = box[2]; content = cwh.first; } + if (fr_weight(*spec) > 0) return total; + if (spec->is("auto") && !horizontal) return total; + if (spec->is_int() || (spec->is_str() && ((!spec->s.empty() && spec->s.back() == '%') || spec->s == "auto"))) { + return child->fixed_len(*spec, total, content) + (horizontal ? child->chrome_h() : child->chrome_w()); + } + return total; +} + +int TuiPanel::cross_pos(int child_size, const std::array& box, bool horizontal) const { + const std::string& cross_align = horizontal ? valign_ : align_; + int start = horizontal ? box[1] : box[0]; + int total = horizontal ? box[3] : box[2]; + if (cross_align == "center" || cross_align == "middle") return start + std::max(0, (total - child_size) / 2); + if (cross_align == "right" || cross_align == "bottom") return start + std::max(0, total - child_size); + return start; +} + +int TuiPanel::place_pos(const TuiSpec& spec, int total, int size) { + if (spec.is_none()) return 0; + if (spec.is_int()) return spec.n >= 0 ? static_cast(spec.n) : total - size + static_cast(spec.n); + if (spec.s == "center") return std::max(0, (total - size) / 2); + auto p = pct(spec, total); + if (p) return *p; + return 0; +} + +void TuiPanel::place_float(TuiPanel* child, const std::array& box) { + auto cwh = child->measured(); + int child_w = child->fixed_len(child->w_, box[2], cwh.first) + child->chrome_w(); + int child_h = child->fixed_len(child->h_, box[3], cwh.second) + child->chrome_h(); + int at_x = box[0] + place_pos(child->x_, box[2], child_w); + int at_y = box[1] + place_pos(child->y_, box[3], child_h); + child->arrange(at_x, at_y, child_w, child_h, content_clip()); +} + +void TuiPanel::clamp_scroll() { + auto cwh = measured(); + measure_seen_ = cwh; + set_scroll_x(std::max(0, std::min(scroll_x_, std::max(0, cwh.first - content_w())))); + set_scroll_y(std::max(0, std::min(scroll_y_, std::max(0, cwh.second - content_h())))); +} + +TuiRect TuiPanel::content_rect() const { + int edge = border_ ? 1 : 0; + return {{std::max(clip[0], ax + edge + padding_[3]), std::max(clip[1], ay + edge + padding_[0]), + std::min(clip[2], ax + aw - edge - padding_[1]), std::min(clip[3], ay + ah - edge - padding_[2])}}; +} + +void TuiPanel::scroll_by(int dx, int dy) { + set_scroll_x(scroll_x_ + dx); + set_scroll_y(scroll_y_ + dy); + clamp_scroll(); + if (app) touch(); +} + +// ---- paint ------------------------------------------------------------------- + +SpanRows TuiPanel::content_rows() { + int width = content_w(); + SpanRow fill; + if (width) fill.push_back(TuiSpan(UStr(static_cast(width), U' '), style_)); + return SpanRows(static_cast(content_h()), fill); +} + +SpanRows TuiPanel::paint() { + int width = content_w(), height = content_h(); + SpanRows rows; + SpanRows content = content_rows(); + for (int row_pos = 0; row_pos < height; row_pos++) { + long idx = static_cast(scroll_y_) + row_pos; + SpanRow source = idx < static_cast(content.size()) ? content[static_cast(idx)] : SpanRow(); + if (scroll_x_ == 0 && !source.empty() && TuiSpans::width(source) == width) rows.push_back(source); + else rows.push_back(TuiSpans::slice(source, scroll_x_, width, style_)); + } + int pad_top = padding_[0], pad_right = padding_[1], pad_bottom = padding_[2], pad_left = padding_[3]; + int edge = border_ ? 1 : 0; + if (pad_left || pad_right) { + for (auto& spans : rows) { + SpanRow out; + if (pad_left) out.push_back(TuiSpan(UStr(static_cast(pad_left), U' '), style_)); + out.insert(out.end(), spans.begin(), spans.end()); + if (pad_right) out.push_back(TuiSpan(UStr(static_cast(pad_right), U' '), style_)); + spans = std::move(out); + } + } + int inner_w = width + pad_left + pad_right; + SpanRow blank; + if (inner_w) blank.push_back(TuiSpan(UStr(static_cast(inner_w), U' '), style_)); + SpanRows framed; + for (int i = 0; i < pad_top; i++) framed.push_back(blank); + framed.insert(framed.end(), rows.begin(), rows.end()); + for (int i = 0; i < pad_bottom; i++) framed.push_back(blank); + rows = std::move(framed); + if (edge) rows = add_border(rows, inner_w); + while (static_cast(rows.size()) < ah) { + SpanRow r; + if (aw) r.push_back(TuiSpan(UStr(static_cast(aw), U' '), style_)); + rows.push_back(r); + } + rows.resize(static_cast(ah)); + if (aw < chrome_w() + 2) { + for (auto& spans : rows) spans = TuiSpans::pad(spans, aw, style_); + } + return rows; +} + +TuiCellRows TuiPanel::paint_cells(TuiCompositor& compositor) { return compositor.cells_from(paint(), aw); } + +SpanRows TuiPanel::add_border(const SpanRows& rows, int inner_w) { + const UStr &tl = rounded_ ? kRTL : kTL, &tr = rounded_ ? kRTR : kTR, &bl = rounded_ ? kRBL : kBL, &br = rounded_ ? kRBR : kBR; + StyleP line = border_style_; + if (focus_border_style_ && app && app->focused == this) line = focus_border_style_; + SpanRow top; + top.push_back(TuiSpan(tl + U"─", line)); + UStr stripped = TuiStr::strip(title); + UStr title_text = stripped.empty() ? UStr() : U" " + stripped + U" "; + int room = std::max(0, inner_w - 2); + if (static_cast(title_text.size()) > room) { + title_text = TuiStr::head(title_text, std::max(0, room - 1)) + (room ? UStr(U"…") : UStr()); + } + top.push_back(TuiSpan(title_text, title_style_)); + top.push_back(TuiSpan(TuiStr::repeat(U'─', std::max(0, room - static_cast(title_text.size()) + 1)) + tr, line)); + SpanRow bottom; + bottom.push_back(TuiSpan(bl + TuiStr::repeat(U'─', inner_w) + br, line)); + SpanRows framed{top}; + for (auto& spans : rows) { + SpanRow r; + r.push_back(TuiSpan(U"│", line)); + r.insert(r.end(), spans.begin(), spans.end()); + r.push_back(TuiSpan(U"│", line)); + framed.push_back(std::move(r)); + } + framed.push_back(bottom); + return framed; +} + +// ---- animation ------------------------------------------------------------------ + +double TuiPanel::anim_start(const std::string& prop) { + auto numeric = [&](const TuiSpec& v) { return v.is_int() ? static_cast(v.n) : 0.0; }; + bool geometry = prop == "x" || prop == "y" || prop == "w" || prop == "h"; + if (!geometry) { + if (prop == "scroll_x") return scroll_x_; + if (prop == "scroll_y") return scroll_y_; + if (prop == "z") return z_; + if (prop == "gap") return gap_; + return 0.0; + } + if (prop == "w") return static_cast(aw - chrome_w()); + if (prop == "h") return static_cast(ah - chrome_h()); + if (!parent) return numeric(prop == "x" ? x_ : y_); + int edge = parent->border_ ? 1 : 0; + const auto& pad = parent->padding_; + if (prop == "x") return static_cast(ax - offset_.first - (parent->ax + edge + pad[3])); + return static_cast(ay - offset_.second - (parent->ay + edge + pad[0])); +} + +void TuiPanel::set_numeric(const std::string& prop, double v) { + long iv = static_cast(std::nearbyint(v)); + if (prop == "x") set_x(TuiSpec(iv)); + else if (prop == "y") set_y(TuiSpec(iv)); + else if (prop == "w") set_w(TuiSpec(iv)); + else if (prop == "h") set_h(TuiSpec(iv)); + else if (prop == "scroll_x") set_scroll_x(static_cast(iv)); + else if (prop == "scroll_y") set_scroll_y(static_cast(iv)); + else if (prop == "z") set_z(static_cast(iv)); + else if (prop == "gap") set_gap(static_cast(iv)); +} + +std::shared_ptr TuiPanel::animate(const std::string& prop, double to, std::optional duration, + std::optional speed, const std::string& easing, double delay, + std::function on_done) { + double dur; + if (!duration) { + double distance = std::fabs(to - anim_start(prop)); + dur = (speed && *speed != 0.0) ? distance / *speed : 0.4; + } else { + dur = *duration; + } + auto anim = std::make_shared(Animation{this, prop, to, std::max(0.001, dur), easing, std::max(0.0, delay), std::move(on_done)}); + if (app) { + app->animations.push_back(anim); + app->wake(); + } else { + pending_anims_.push_back(anim); + } + return anim; +} + +void TuiPanel::focus() { if (app) app->set_focus(this); } +void TuiPanel::blur() { if (app && app->focused == this) app->set_focus(nullptr); } +void TuiPanel::close() { if (app) app->close_panel(this); } + +// ---- rows / cols ----------------------------------------------------------------- + +TuiRows::TuiRows(TuiPanelOpts opts, std::initializer_list kids) : TuiPanel([&] { if (!opts.flow) opts.flow = "rows"; return opts; }()) { + for (auto& k : kids) add(k); +} +TuiCols::TuiCols(TuiPanelOpts opts, std::initializer_list kids) : TuiPanel([&] { if (!opts.flow) opts.flow = "cols"; return opts; }()) { + for (auto& k : kids) add(k); +} + +} // namespace detz::tui diff --git a/taicpp/src/tui/TuiPanel.h b/taicpp/src/tui/TuiPanel.h new file mode 100644 index 0000000..5c0a990 --- /dev/null +++ b/taicpp/src/tui/TuiPanel.h @@ -0,0 +1,305 @@ +#pragma once +// TuiPanel: base of every component (tai.py TuiPanel, TuiRows, TuiCols). +// Box, children, layout, paint, event dispatch. Geometry specs accept ints +// (cells), "50%" (of parent content box), "1fr" (share of leftover flow space), +// "auto" (measured content) and "center" (x/y only). +// +// Python's __setattr__ change signal becomes explicit setters: geometry names +// relayout, layer/z restack, everything else touches. A scalar written back +// unchanged is not a change. Containers kept on a panel are plain C++ +// containers: code that mutates one calls touch() (python's TuiList/TuiDict/ +// TuiSet did that implicitly). +#include "TuiCell.h" +#include "TuiEvent.h" +#include "TuiStyle.h" +#include +#include +#include +#include +#include +#include +#include +#include +namespace detz::tui { + +class TuiApp; +class TuiCompositor; +class TuiPanel; +using PanelP = std::shared_ptr; +using TuiHandler = std::function; + +// A geometry spec: None, an int, or a string ("auto", "50%", "1fr", "center"). +struct TuiSpec { + enum Kind { None, Int, Str } kind{None}; + long n{0}; + std::string s; + TuiSpec() = default; + TuiSpec(std::nullptr_t) {} + TuiSpec(int v) : kind(Int), n(v) {} + TuiSpec(long v) : kind(Int), n(v) {} + TuiSpec(const char* v) : kind(Str), s(v) {} + TuiSpec(const std::string& v) : kind(Str), s(v) {} + bool is_none() const { return kind == None; } + bool is_int() const { return kind == Int; } + bool is_str() const { return kind == Str; } + bool is(const char* text) const { return kind == Str && s == text; } + bool operator==(const TuiSpec& o) const { return kind == o.kind && n == o.n && s == o.s; } + bool operator!=(const TuiSpec& o) const { return !(*this == o); } +}; + +using TuiRect = std::array; // x0, y0, x1, y1 + +// Constructor kwargs of TuiPanel. optional<> marks the fields a widget gives its own default +// (python kwargs.setdefault), so "not passed" and "passed" stay distinguishable. +struct TuiPanelOpts { + std::string id; // "" is None + std::optional title; + std::optional x, y; + std::optional w, h; // default "auto" + std::optional flow; // default "rows"; "" is None + std::string dock; // "" is None + std::optional layer; // "" is None + std::optional z; + std::optional> offset; + std::string align{"left"}, valign{"top"}; + std::array padding{{0, 0, 0, 0}}; // top, right, bottom, left + int gap{0}; + std::optional border; + StyleP border_style; + StyleP title_style; + std::optional rounded; + StyleP focus_border_style; + StyleP style; + bool visible{true}; + std::optional focusable; + bool enabled{true}; + std::optional modal; + std::optional scrollable; +}; + +class TuiPanel : public std::enable_shared_from_this { +public: + explicit TuiPanel(TuiPanelOpts opts = {}); + virtual ~TuiPanel(); + TuiPanel(const TuiPanel&) = delete; + TuiPanel& operator=(const TuiPanel&) = delete; + + template static std::shared_ptr make(A&&... args) { return std::make_shared(std::forward(args)...); } + + static std::array norm_padding(int all); + static std::array norm_padding(int vertical, int horizontal); + static std::array norm_padding(int top, int right, int bottom, int left); + + // ---- identity and state ---- + std::string id; + UStr title; + long seq; + bool closable{false}; // dynamic attribute some demo panels carry + TuiPanel* parent{nullptr}; + TuiApp* app{nullptr}; + std::vector children; + int ax{0}, ay{0}, aw{0}, ah{0}; + TuiRect clip{{0, 0, 0, 0}}; + TuiCellRows* cells_cache() { return cells_.get(); } + + // read accessors for the watched attributes + const TuiSpec& x() const { return x_; } + const TuiSpec& y() const { return y_; } + const TuiSpec& w() const { return w_; } + const TuiSpec& h() const { return h_; } + const std::string& flow() const { return flow_; } + const std::string& dock() const { return dock_; } + const std::string& layer() const { return layer_; } + int z() const { return z_; } + std::pair offset() const { return offset_; } + const std::string& align() const { return align_; } + const std::string& valign() const { return valign_; } + const std::array& padding() const { return padding_; } + int gap() const { return gap_; } + bool border() const { return border_; } + bool rounded() const { return rounded_; } + const StyleP& style() const { return style_; } + const StyleP& border_style() const { return border_style_; } + const StyleP& title_style() const { return title_style_; } + const StyleP& focus_border_style() const { return focus_border_style_; } + bool visible() const { return visible_; } + bool focusable() const { return focusable_; } + bool enabled() const { return enabled_; } + bool modal() const { return modal_; } + bool scrollable() const { return scrollable_; } + int scroll_x() const { return scroll_x_; } + int scroll_y() const { return scroll_y_; } + + // write accessors: each one is the python attribute assignment (change signal included) + void set_x(const TuiSpec& v); + void set_y(const TuiSpec& v); + void set_w(const TuiSpec& v); + void set_h(const TuiSpec& v); + void set_flow(const std::string& v); + void set_dock(const std::string& v); + void set_offset(std::pair v); + void set_align(const std::string& v); + void set_valign(const std::string& v); + void set_padding(const std::array& v); + void set_gap(int v); + void set_border(bool v); + void set_rounded(bool v); + void set_layer(const std::string& v); + void set_z(int v); + void set_visible(bool v); + void set_focusable(bool v); + void set_enabled(bool v); + void set_modal(bool v); + void set_scrollable(bool v); + void set_scroll_x(int v); + void set_scroll_y(int v); + void set_style(const StyleP& v); + void set_border_style(const StyleP& v); + void set_title_style(const StyleP& v); + void set_focus_border_style(const StyleP& v); + void set_title(const UStr& v); + // generic public-attribute write on a widget: touches when mounted and changed + void attr_changed() { if (app) touch(); } + + // handlers (python on_ attributes) + void set_handler(const std::string& event_name, TuiHandler fn); + void clear_handler(const std::string& event_name); + bool has_handler(const std::string& event_name) const; + virtual bool ticks() const { return has_handler("tick"); } + + // ---- tree ---- + PanelP add(PanelP child); + void add_all(std::initializer_list kids); + PanelP remove(const PanelP& child); + PanelP remove(TuiPanel* child); + void clear(); + std::vector walk(); + TuiPanel* find(const std::string& id); + TuiPanel* root(); + int depth() const; + bool is_shown() const; + int effective_z() const; + int chrome_w() const; + int chrome_h() const; + PanelP self() { return shared_from_this(); } + bool contains_child(const TuiPanel* child) const; + + // ---- events ---- + TuiEvent& dispatch(TuiEvent& event); + TuiEvent& emit(TuiEvent& event); + template void emit_new(E event) { event.target = this; dispatch(event); } + virtual void on_event(TuiEvent& event); // class-level on_ methods (virtual per event) + virtual void on_key(TuiKey&) {} + virtual void on_click(TuiClick&) {} + virtual void on_scroll(TuiScroll& event); + virtual void on_paste(TuiPaste&) {} + virtual void on_blur(TuiBlur&) {} + virtual void on_focus(TuiFocus&) {} + virtual void on_mount(TuiMount&) {} + virtual void on_unmount(TuiUnmount&) {} + virtual void on_select(TuiSelect&) {} + virtual void on_press(TuiPress&) {} + virtual void on_close(TuiClose&) {} + virtual void on_change(TuiChange&) {} + virtual void on_resize(TuiResize&) {} + virtual void on_tick(TuiTick&) {} + + // ---- geometry ---- + virtual bool memoize_measure() const { return true; } + virtual bool shifts() const { return false; } + void mark_pending(); + std::pair measured(); + void forget_measure(); + virtual std::pair measure(); + bool content_sized() const; + virtual void arrange(int x, int y, int w, int h, TuiRect clip); + static TuiRect intersect(const TuiRect& a, const TuiRect& b); + TuiRect content_rect() const; + int content_w() const { return std::max(0, aw - chrome_w()); } + int content_h() const { return std::max(0, ah - chrome_h()); } + void clamp_scroll(); + void scroll_by(int dx, int dy); + virtual void touch(); + + // ---- paint ---- + virtual SpanRows content_rows(); + virtual SpanRows paint(); + virtual TuiCellRows paint_cells(TuiCompositor& compositor); + SpanRows add_border(const SpanRows& rows, int inner_w); + + // ---- animation ---- + struct Animation { + TuiPanel* panel; std::string prop; double to; double duration; std::string easing; double delay; + std::function on_done; bool started{false}; double started_at{0}; double from{0}; + }; + double anim_start(const std::string& prop); + void set_numeric(const std::string& prop, double value); // setattr(panel, prop, value) for animated props + std::shared_ptr animate(const std::string& prop, double to, std::optional duration = std::nullopt, + std::optional speed = std::nullopt, const std::string& easing = "in_out_cubic", + double delay = 0.0, std::function on_done = nullptr); + + void focus(); + void blur(); + void close(); + + // ---- bookkeeping used by the app/compositor ---- + void bind_app(TuiApp* a); + void unbind_app(); + std::shared_ptr cells_; + std::pair cells_size_{-1, -1}; + int shift_{0}; + std::optional> arrange_; // x, y, w, h, clip(4) + std::optional> geom_; // ax, ay, aw, ah, clip(4) + std::optional> measure_seen_; + std::optional> mcache_; + bool owes_arrange_{false}; + std::vector> pending_anims_; + + // layout pass state (python class attributes _measure_memo, _stale, _arranged) + static std::unordered_map>* measure_memo; + static bool stale; + static std::vector* arranged; + static long seq_counter; + +protected: + void adopt_style(const StyleP& s); + std::vector> adopted_; + void place_dock(TuiPanel* child, std::array& box); + void place_flow(const std::vector& flowing, std::array& box, bool horizontal); + int cross_size(TuiPanel* child, const std::array& box, bool horizontal); + int cross_pos(int child_size, const std::array& box, bool horizontal) const; + void place_float(TuiPanel* child, const std::array& box); + static int place_pos(const TuiSpec& spec, int total, int size); + static double fr_weight(const TuiSpec& spec); + static std::optional pct(const TuiSpec& spec, int total); + int fixed_len(const TuiSpec& spec, int total, int content_len) const; + TuiRect content_clip() const; + bool scroll_in_range(bool horizontal, int value) const; + + TuiSpec x_, y_, w_, h_; + std::string flow_, dock_, layer_; + int z_{0}; + std::pair offset_{0, 0}; + std::string align_, valign_; + std::array padding_{{0, 0, 0, 0}}; + int gap_{0}; + bool border_{false}, rounded_{false}; + StyleP border_style_, title_style_, focus_border_style_, style_; + bool visible_{true}, focusable_{false}, enabled_{true}, modal_{false}, scrollable_{false}; + int scroll_x_{0}, scroll_y_{0}; + std::map handlers_; + friend class TuiApp; +}; + +// Vertical flow container. +class TuiRows : public TuiPanel { +public: + explicit TuiRows(TuiPanelOpts opts = {}, std::initializer_list kids = {}); +}; +// Horizontal flow container. +class TuiCols : public TuiPanel { +public: + explicit TuiCols(TuiPanelOpts opts = {}, std::initializer_list kids = {}); +}; + +} // namespace detz::tui diff --git a/taicpp/src/tui/TuiProgress.cpp b/taicpp/src/tui/TuiProgress.cpp new file mode 100644 index 0000000..749633c --- /dev/null +++ b/taicpp/src/tui/TuiProgress.cpp @@ -0,0 +1,22 @@ +#include "TuiProgress.h" +#include +#include +namespace detz::tui { + +TuiProgress::TuiProgress(double value, TuiPanelOpts opts) : TuiPanel(std::move(opts)) { set(value); } + +void TuiProgress::set(double value) { + value_ = std::max(0.0, std::min(1.0, value)); + if (app) touch(); +} + +SpanRows TuiProgress::content_rows() { + int width = content_w(); + int filled = static_cast(width * value_); + char pct[16]; + std::snprintf(pct, sizeof pct, " %3d%%", static_cast(value_ * 100)); + UStr bar = TuiStr::repeat(U'█', filled) + TuiStr::repeat(U'░', std::max(0, width - filled - 7)) + TuiStr::u(pct); + return SpanRows{SpanRow{TuiSpan(TuiStr::ljust(TuiStr::head(bar, width), width), style())}}; +} + +} // namespace detz::tui diff --git a/taicpp/src/tui/TuiProgress.h b/taicpp/src/tui/TuiProgress.h new file mode 100644 index 0000000..655cfd3 --- /dev/null +++ b/taicpp/src/tui/TuiProgress.h @@ -0,0 +1,17 @@ +#pragma once +// TuiProgress: fraction bar with percent label (tai.py TuiProgress). +#include "TuiPanel.h" +namespace detz::tui { + +class TuiProgress : public TuiPanel { +public: + explicit TuiProgress(double value = 0.0, TuiPanelOpts opts = {}); + double value() const { return value_; } + void set(double value); + std::pair measure() override { return {20, 1}; } + SpanRows content_rows() override; +private: + double value_{0.0}; +}; + +} // namespace detz::tui diff --git a/taicpp/src/tui/TuiRich.cpp b/taicpp/src/tui/TuiRich.cpp new file mode 100644 index 0000000..38459aa --- /dev/null +++ b/taicpp/src/tui/TuiRich.cpp @@ -0,0 +1,51 @@ +#include "TuiRich.h" +#include +namespace detz::tui { + +TuiRich::TuiRich(SpanRows rows, TuiPanelOpts opts) : TuiPanel(std::move(opts)) { set(std::move(rows)); } + +void TuiRich::set_row(const SpanRow& row) { set(SpanRows{row}); } + +void TuiRich::set(SpanRows rows) { + std::vector>> look; + for (auto& row : rows) { + std::vector> r; + for (auto& span : row) r.emplace_back(span.text, span.style->key()); + look.push_back(std::move(r)); + } + if (have_look_ && look == look_) return; + look_ = std::move(look); + have_look_ = true; + rows_ = std::move(rows); + UStr text; + for (size_t i = 0; i < rows_.size(); i++) { + if (i) text += U"\n"; + for (auto& span : rows_[i]) text += span.text; + } + text_ = text; + if (app) touch(); +} + +void TuiRich::set_text(const UStr& v) { bool same = text_ == v; text_ = v; if (app && !same) touch(); } + +std::pair TuiRich::measure() { + int width = 0; + for (auto& row : rows_) { + int w = 0; + for (auto& span : row) w += static_cast(span.text.size()); + width = std::max(width, w); + } + return {width, std::max(1, static_cast(rows_.size()))}; +} + +SpanRows TuiRich::content_rows() { + int width = content_w(), height = content_h(); + SpanRows out; + for (int pos = 0; pos < height; pos++) { + SpanRow row = pos < static_cast(rows_.size()) ? rows_[static_cast(pos)] : SpanRow(); + out.push_back(TuiSpans::slice(row, 0, width, style())); + } + return out; +} + +} // namespace detz::tui diff --git a/taicpp/src/tui/TuiRich.h b/taicpp/src/tui/TuiRich.h new file mode 100644 index 0000000..e72e993 --- /dev/null +++ b/taicpp/src/tui/TuiRich.h @@ -0,0 +1,25 @@ +#pragma once +// TuiRich: static multi-style rows (tai.py TuiRich). set() takes one span list +// or a list of span lists. Rows pad to the panel width and clip, so callers +// never measure. text mirrors the plain content for tests and status reads. +#include "TuiPanel.h" +namespace detz::tui { + +class TuiRich : public TuiPanel { +public: + explicit TuiRich(SpanRows rows = {}, TuiPanelOpts opts = {}); + void set(SpanRows rows); // list of rows + void set_row(const SpanRow& row); // python: a flat list of spans means one row + const SpanRows& rows() const { return rows_; } + const UStr& text() const { return text_; } + void set_text(const UStr& v); // plain attribute write (touches when mounted and changed) + std::pair measure() override; + SpanRows content_rows() override; +private: + SpanRows rows_; + UStr text_; + std::vector>> look_; + bool have_look_{false}; +}; + +} // namespace detz::tui diff --git a/taicpp/src/tui/TuiStr.cpp b/taicpp/src/tui/TuiStr.cpp new file mode 100644 index 0000000..09108b0 --- /dev/null +++ b/taicpp/src/tui/TuiStr.cpp @@ -0,0 +1,191 @@ +#include "TuiStr.h" +#include +#include +namespace detz::tui { + +UStr TuiStr::u(const std::string& in) { + UStr out; + size_t i = 0, n = in.size(); + while (i < n) { + unsigned char c = static_cast(in[i]); + char32_t cp; + size_t need; + if (c < 0x80) { cp = c; need = 1; } + else if (c >= 0xC2 && c <= 0xDF) { cp = c & 0x1F; need = 2; } + else if (c >= 0xE0 && c <= 0xEF) { cp = c & 0x0F; need = 3; } + else if (c >= 0xF0 && c <= 0xF4) { cp = c & 0x07; need = 4; } + else { out.push_back(0xFFFD); i++; continue; } + if (i + need > n) { out.push_back(0xFFFD); i++; continue; } + bool ok = true; + for (size_t k = 1; k < need; k++) { + unsigned char cc = static_cast(in[i + k]); + if ((cc & 0xC0) != 0x80) { ok = false; break; } + cp = (cp << 6) | (cc & 0x3F); + } + if (!ok || (need == 3 && cp < 0x800) || (need == 4 && (cp < 0x10000 || cp > 0x10FFFF)) || (cp >= 0xD800 && cp <= 0xDFFF)) { + out.push_back(0xFFFD); i++; continue; + } + out.push_back(cp); + i += need; + } + return out; +} + +std::string TuiStr::s(char32_t cp) { + std::string out; + if (cp < 0x80) out.push_back(static_cast(cp)); + else if (cp < 0x800) { out.push_back(static_cast(0xC0 | (cp >> 6))); out.push_back(static_cast(0x80 | (cp & 0x3F))); } + else if (cp < 0x10000) { out.push_back(static_cast(0xE0 | (cp >> 12))); out.push_back(static_cast(0x80 | ((cp >> 6) & 0x3F))); out.push_back(static_cast(0x80 | (cp & 0x3F))); } + else { out.push_back(static_cast(0xF0 | (cp >> 18))); out.push_back(static_cast(0x80 | ((cp >> 12) & 0x3F))); out.push_back(static_cast(0x80 | ((cp >> 6) & 0x3F))); out.push_back(static_cast(0x80 | (cp & 0x3F))); } + return out; +} + +std::string TuiStr::s(const UStr& text) { + std::string out; + out.reserve(text.size()); + for (char32_t cp : text) out += s(cp); + return out; +} + +UStr TuiStr::repeat(char32_t ch, long count) { return count > 0 ? UStr(static_cast(count), ch) : UStr(); } + +static void norm_slice(long len, long& start, long& stop) { + if (start < 0) start += len; + if (stop < 0) stop += len; + start = std::max(0L, std::min(start, len)); + stop = std::max(0L, std::min(stop, len)); +} + +UStr TuiStr::slice(const UStr& text, long start, long stop) { + long len = static_cast(text.size()); + norm_slice(len, start, stop); + if (start >= stop) return UStr(); + return text.substr(static_cast(start), static_cast(stop - start)); +} +UStr TuiStr::from(const UStr& text, long start) { return slice(text, start, static_cast(text.size())); } +UStr TuiStr::head(const UStr& text, long stop) { return slice(text, 0, stop); } + +UStr TuiStr::ljust(const UStr& text, long width) { + long len = static_cast(text.size()); + return len >= width ? text : text + repeat(U' ', width - len); +} +UStr TuiStr::rjust(const UStr& text, long width) { + long len = static_cast(text.size()); + return len >= width ? text : repeat(U' ', width - len) + text; +} +UStr TuiStr::center(const UStr& text, long width) { + long len = static_cast(text.size()); + if (len >= width) return text; + long marg = width - len; + long left = marg / 2 + (marg & width & 1); // CPython: left = marg/2 + (marg & width & 1) + return repeat(U' ', left) + text + repeat(U' ', marg - left); +} + +bool TuiStr::isspace(char32_t c) { + return c == ' ' || (c >= 9 && c <= 13) || (c >= 0x1C && c <= 0x1F) || c == 0x85 || c == 0xA0 || c == 0x1680 || + (c >= 0x2000 && c <= 0x200A) || c == 0x2028 || c == 0x2029 || c == 0x202F || c == 0x205F || c == 0x3000; +} +bool TuiStr::isalnum(char32_t c) { + if (c < 0x80) return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z'); + return std::iswalnum(static_cast(c)) != 0; +} +bool TuiStr::isdigit(char32_t c) { return c >= '0' && c <= '9'; } +bool TuiStr::isupper(char32_t c) { return c < 0x80 ? (c >= 'A' && c <= 'Z') : std::iswupper(static_cast(c)) != 0; } +bool TuiStr::islower(char32_t c) { return c < 0x80 ? (c >= 'a' && c <= 'z') : std::iswlower(static_cast(c)) != 0; } +bool TuiStr::all_space(const UStr& t) { + if (t.empty()) return false; + for (char32_t c : t) if (!isspace(c)) return false; + return true; +} +UStr TuiStr::rstrip(const UStr& t) { + size_t e = t.size(); + while (e > 0 && isspace(t[e - 1])) e--; + return t.substr(0, e); +} +UStr TuiStr::lstrip(const UStr& t) { + size_t b = 0; + while (b < t.size() && isspace(t[b])) b++; + return t.substr(b); +} +UStr TuiStr::strip(const UStr& t) { return lstrip(rstrip(t)); } +UStr TuiStr::lower(const UStr& t) { + UStr o = t; + for (auto& c : o) c = c < 0x80 ? (c >= 'A' && c <= 'Z' ? c + 32 : c) : static_cast(std::towlower(static_cast(c))); + return o; +} +UStr TuiStr::upper(const UStr& t) { + UStr o = t; + for (auto& c : o) c = c < 0x80 ? (c >= 'a' && c <= 'z' ? c - 32 : c) : static_cast(std::towupper(static_cast(c))); + return o; +} +std::vector TuiStr::split(const UStr& t, char32_t sep) { + std::vector out; + size_t from = 0; + while (true) { + size_t at = t.find(sep, from); + if (at == UStr::npos) { out.push_back(t.substr(from)); break; } + out.push_back(t.substr(from, at - from)); + from = at + 1; + } + return out; +} +std::vector TuiStr::splitlines(const UStr& t) { + std::vector out; + size_t i = 0, start = 0, n = t.size(); + while (i < n) { + char32_t c = t[i]; + if (c == '\n' || c == '\r' || c == 0x0B || c == 0x0C || c == 0x1C || c == 0x1D || c == 0x1E || c == 0x85 || c == 0x2028 || c == 0x2029) { + out.push_back(t.substr(start, i - start)); + if (c == '\r' && i + 1 < n && t[i + 1] == '\n') i++; + start = i + 1; + } + i++; + } + if (start < n) out.push_back(t.substr(start)); + return out; +} +UStr TuiStr::join(const std::vector& parts, const UStr& sep) { + UStr o; + for (size_t i = 0; i < parts.size(); i++) { if (i) o += sep; o += parts[i]; } + return o; +} +UStr TuiStr::replace(const UStr& t, const UStr& from, const UStr& to) { + if (from.empty()) return t; + UStr o; + size_t pos = 0; + while (true) { + size_t at = t.find(from, pos); + if (at == UStr::npos) { o += t.substr(pos); break; } + o += t.substr(pos, at - pos); + o += to; + pos = at + from.size(); + } + return o; +} +bool TuiStr::startswith(const UStr& t, const UStr& p) { return t.size() >= p.size() && t.compare(0, p.size(), p) == 0; } +bool TuiStr::endswith(const UStr& t, const UStr& p) { return t.size() >= p.size() && t.compare(t.size() - p.size(), p.size(), p) == 0; } +long TuiStr::find(const UStr& t, const UStr& n, long from) { + if (from < 0) from = 0; + size_t at = t.find(n, static_cast(from)); + return at == UStr::npos ? -1 : static_cast(at); +} +UStr TuiStr::expandtabs(const UStr& t, int tabsize) { + UStr o; + long col = 0; + for (char32_t c : t) { + if (c == '\t') { + if (tabsize > 0) { long add = tabsize - (col % tabsize); o += repeat(U' ', add); col += add; } + } else { + o.push_back(c); + if (c == '\n' || c == '\r') col = 0; else col++; + } + } + return o; +} +std::string TuiStr::strip_ascii(const std::string& t) { + size_t b = 0, e = t.size(); + while (b < e && (t[b] == ' ' || (t[b] >= 9 && t[b] <= 13))) b++; + while (e > b && (t[e - 1] == ' ' || (t[e - 1] >= 9 && t[e - 1] <= 13))) e--; + return t.substr(b, e - b); +} +} // namespace detz::tui diff --git a/taicpp/src/tui/TuiStr.h b/taicpp/src/tui/TuiStr.h new file mode 100644 index 0000000..2786790 --- /dev/null +++ b/taicpp/src/tui/TuiStr.h @@ -0,0 +1,44 @@ +#pragma once +// TuiStr: Python-str semantics over code points. All widget text is a UStr +// (std::u32string) so len(), slicing and padding count code points exactly as +// tai.py does. Static utility class, no state. +#include +#include +#include +namespace detz::tui { +using UStr = std::u32string; + +class TuiStr { +public: + static UStr u(const std::string& utf8); + static std::string s(const UStr& text); + static std::string s(char32_t ch); + static UStr repeat(char32_t ch, long count); + static UStr slice(const UStr& text, long start, long stop); // python text[start:stop], clamps, negatives allowed + static UStr from(const UStr& text, long start); // text[start:] + static UStr head(const UStr& text, long stop); // text[:stop] + static UStr ljust(const UStr& text, long width); + static UStr rjust(const UStr& text, long width); + static UStr center(const UStr& text, long width); // CPython str.center padding rule + static UStr rstrip(const UStr& text); + static UStr lstrip(const UStr& text); + static UStr strip(const UStr& text); + static UStr lower(const UStr& text); + static UStr upper(const UStr& text); + static std::vector split(const UStr& text, char32_t sep); // str.split(sep) + static std::vector splitlines(const UStr& text); // str.splitlines() + static UStr join(const std::vector& parts, const UStr& sep); + static UStr replace(const UStr& text, const UStr& from, const UStr& to); + static bool isspace(char32_t ch); + static bool isalnum(char32_t ch); + static bool isdigit(char32_t ch); + static bool isupper(char32_t ch); + static bool islower(char32_t ch); + static bool all_space(const UStr& text); // non-empty and all isspace (python str.isspace) + static bool startswith(const UStr& text, const UStr& prefix); + static bool endswith(const UStr& text, const UStr& suffix); + static long find(const UStr& text, const UStr& needle, long from = 0); // -1 when missing + static UStr expandtabs(const UStr& text, int tabsize); + static std::string strip_ascii(const std::string& text); +}; +} // namespace detz::tui diff --git a/taicpp/src/tui/TuiStyle.cpp b/taicpp/src/tui/TuiStyle.cpp new file mode 100644 index 0000000..a2ba4a4 --- /dev/null +++ b/taicpp/src/tui/TuiStyle.cpp @@ -0,0 +1,244 @@ +#include "TuiStyle.h" +#include "TuiPanel.h" +#include "TuiTheme.h" +#include +#include +namespace detz::tui { + +bool TuiColor::truthy() const { + switch (kind) { + case None: return false; + case Int: return n != 0; + case Str: return !s.empty(); + case Rgb: return true; + } + return false; +} +bool TuiColor::operator==(const TuiColor& o) const { + if (kind != o.kind) return false; + switch (kind) { + case None: return true; + case Int: return n == o.n; + case Str: return s == o.s; + case Rgb: return c == o.c; + } + return false; +} + +TuiWatched::~TuiWatched() = default; +void TuiWatched::adopt(TuiPanel* panel) { + if (std::find(owners_.begin(), owners_.end(), panel) == owners_.end()) owners_.push_back(panel); +} +void TuiWatched::disown(TuiPanel* panel) { + owners_.erase(std::remove(owners_.begin(), owners_.end(), panel), owners_.end()); +} +void TuiWatched::changed() { + std::vector copy = owners_; + for (TuiPanel* panel : copy) panel->touch(); +} + +TuiStyle::TuiStyle(TuiColor fg, TuiColor bg, bool bold, bool dim, bool underline, bool reverse, bool strike) + : fg_(std::move(fg)), bg_(std::move(bg)), bold_(bold), dim_(dim), underline_(underline), reverse_(reverse), strike_(strike) {} +TuiStyle::TuiStyle(const TuiStyle& o) + : TuiWatched(), fg_(o.fg_), bg_(o.bg_), bold_(o.bold_), dim_(o.dim_), underline_(o.underline_), reverse_(o.reverse_), strike_(o.strike_) {} +TuiStyle& TuiStyle::operator=(const TuiStyle& o) { + fg_ = o.fg_; bg_ = o.bg_; bold_ = o.bold_; dim_ = o.dim_; underline_ = o.underline_; reverse_ = o.reverse_; strike_ = o.strike_; + have_key_ = false; + return *this; +} + +void TuiStyle::set_fg(const TuiColor& v) { if (fg_ == v) return; fg_ = v; invalidate(); } +void TuiStyle::set_bg(const TuiColor& v) { if (bg_ == v) return; bg_ = v; invalidate(); } +void TuiStyle::set_bold(bool v) { if (bold_ == v) return; bold_ = v; invalidate(); } +void TuiStyle::set_dim(bool v) { if (dim_ == v) return; dim_ = v; invalidate(); } +void TuiStyle::set_underline(bool v) { if (underline_ == v) return; underline_ = v; invalidate(); } +void TuiStyle::set_reverse(bool v) { if (reverse_ == v) return; reverse_ = v; invalidate(); } +void TuiStyle::set_strike(bool v) { if (strike_ == v) return; strike_ = v; invalidate(); } + +const TuiStyleKey& TuiStyle::key() const { + if (!have_key_) { + key_.fg = fg_; key_.bg = bg_; key_.bold = bold_; key_.dim = dim_; + key_.underline = underline_; key_.reverse = reverse_; key_.strike = strike_; + have_key_ = true; + } + return key_; +} + +std::string TuiStyle::sgr() const { return sgr_from_key(key()); } + +std::string TuiStyle::sgr_from_key(const TuiStyleKey& k) { + std::string out = "\x1b[0"; + if (k.bold) out += ";1"; + if (k.dim) out += ";2"; + if (k.underline) out += ";4"; + if (k.reverse) out += ";7"; + if (k.strike) out += ";9"; + std::string fg = color_sgr(k.fg, false); + if (!fg.empty()) out += ";" + fg; + std::string bg = color_sgr(k.bg, true); + if (!bg.empty()) out += ";" + bg; + return out + "m"; +} + +StyleP TuiStyle::merged(const TuiStyle& other) const { + auto m = std::make_shared(fg_, bg_, bold_, dim_, underline_, reverse_, strike_); + if (!other.fg_.is_none()) m->set_fg(other.fg_); + if (!other.bg_.is_none()) m->set_bg(other.bg_); + m->set_bold(m->bold() || other.bold_); + m->set_dim(m->dim() || other.dim_); + m->set_underline(m->underline() || other.underline_); + m->set_reverse(m->reverse() || other.reverse_); + m->set_strike(m->strike() || other.strike_); + return m; +} + +namespace { +int color_index(const std::string& name) { + static const char* names[] = {"black", "red", "green", "yellow", "blue", "magenta", "cyan", "white"}; + for (int i = 0; i < 8; i++) if (name == names[i]) return i; + return -1; +} +bool parse_hex2(const std::string& t, size_t pos, int& out) { + int v = 0; + for (size_t i = pos; i < pos + 2; i++) { + char c = t[i]; + v <<= 4; + if (c >= '0' && c <= '9') v |= c - '0'; + else if (c >= 'a' && c <= 'f') v |= c - 'a' + 10; + else if (c >= 'A' && c <= 'F') v |= c - 'A' + 10; + else return false; + } + out = v; + return true; +} +} // namespace + +std::string TuiStyle::color_sgr(const TuiColor& color, bool background) { + char buf[64]; + switch (color.kind) { + case TuiColor::None: return ""; + case TuiColor::Int: + std::snprintf(buf, sizeof buf, "%d;5;%d", background ? 48 : 38, std::max(0, std::min(255, color.n))); + return buf; + case TuiColor::Rgb: + std::snprintf(buf, sizeof buf, "%d;2;%d;%d;%d", background ? 48 : 38, std::max(0, std::min(255, color.c[0])), + std::max(0, std::min(255, color.c[1])), std::max(0, std::min(255, color.c[2]))); + return buf; + case TuiColor::Str: { + std::string text = TuiStr::s(TuiStr::lower(TuiStr::strip(TuiStr::u(color.s)))); + if (text.compare(0, 5, "role:") == 0) { + TuiColor resolved = TuiThemes::current()->color(text.substr(5)); + return resolved.truthy() ? color_sgr(resolved, background) : ""; + } + if (!text.empty() && text[0] == '#' && text.size() == 7) { + int r, g, b; + if (!parse_hex2(text, 1, r) || !parse_hex2(text, 3, g) || !parse_hex2(text, 5, b)) return ""; + std::snprintf(buf, sizeof buf, "%d;2;%d;%d;%d", background ? 48 : 38, r, g, b); + return buf; + } + bool bright = text.compare(0, 7, "bright_") == 0; + if (bright) text = text.substr(7); + int idx = color_index(text); + if (idx >= 0) { + int base = !background ? (bright ? 90 : 30) : (bright ? 100 : 40); + return std::to_string(base + idx); + } + return ""; + } + } + return ""; +} + +// ---- span helpers ---------------------------------------------------------- + +TuiSpans::Cells TuiSpans::flatten(const SpanRow& spans) { + Cells cells; + for (auto& span : spans) for (char32_t ch : span.text) cells.emplace_back(ch, span.style); + return cells; +} + +SpanRow TuiSpans::runs(const Cells& cells) { + std::vector> out; + const TuiStyle* last = nullptr; + const TuiStyleKey* last_key = nullptr; + TuiStyleKey keep; + for (auto& cell : cells) { + if (cell.second.get() == last) { out.back().first.push_back(cell.first); continue; } + const TuiStyleKey& key = cell.second->key(); + last = cell.second.get(); + if (!out.empty() && last_key && key == *last_key) { out.back().first.push_back(cell.first); continue; } + keep = key; + last_key = &keep; + out.push_back({UStr(1, cell.first), cell.second}); + } + SpanRow row; + for (auto& r : out) row.emplace_back(r.first, r.second); + return row; +} + +long TuiSpans::width(const SpanRow& spans) { + long w = 0; + for (auto& s : spans) w += static_cast(s.text.size()); + return w; +} + +SpanRow TuiSpans::pad(const SpanRow& spans, long width, const StyleP& fill_style) { return slice(spans, 0, width, fill_style); } + +UStr TuiSpans::plain(const SpanRow& spans) { + UStr o; + for (auto& s : spans) o += s.text; + return o; +} + +SpanRow TuiSpans::slice(const SpanRow& spans, long start, long length, const StyleP& fill_style) { + start = std::max(0L, start); + length = std::max(0L, length); + long end = start + length; + SpanRow out; + const TuiStyle* last = nullptr; + TuiStyleKey last_key; + bool have_last_key = false; + long pos = 0; + for (auto& span : spans) { + if (pos >= end) break; + const UStr& text = span.text; + long size = static_cast(text.size()); + long nxt = pos + size; + if (nxt > start) { + long cut_lo = start > pos ? start - pos : 0; + long cut_hi = end < nxt ? end - pos : size; + UStr piece = (cut_lo == 0 && cut_hi == size) ? text : TuiStr::slice(text, cut_lo, cut_hi); + if (!piece.empty()) { + const StyleP& style = span.style; + if (style.get() == last) { + out.back().text += piece; + } else { + const TuiStyleKey& key = style->key(); + if (!out.empty() && have_last_key && key == last_key) { + out.back().text += piece; + } else { + out.emplace_back(piece, style); + last_key = key; + have_last_key = true; + } + last = style.get(); + } + } + } + pos = nxt; + } + long covered = pos > start ? std::min(pos, end) - start : 0; + if (covered < length) { + UStr fill(static_cast(length - covered), U' '); + if (fill_style.get() == last) { + out.back().text += fill; + } else { + const TuiStyleKey& key = fill_style->key(); + if (!out.empty() && have_last_key && key == last_key) out.back().text += fill; + else out.emplace_back(fill, fill_style); + } + } + return out; +} + +} // namespace detz::tui diff --git a/taicpp/src/tui/TuiStyle.h b/taicpp/src/tui/TuiStyle.h new file mode 100644 index 0000000..1725bb9 --- /dev/null +++ b/taicpp/src/tui/TuiStyle.h @@ -0,0 +1,121 @@ +#pragma once +// TuiColor, TuiStyleKey, TuiWatched, TuiStyle, TuiSpan (tai.py TuiWatched, +// TuiStyle, TuiSpan). Colors are names ("red", "bright_cyan", "role:accent"), +// 256-color ints, "#rrggbb" strings or (r,g,b) triples, exactly as in Python. +#include "TuiStr.h" +#include +#include +#include +#include +#include +namespace detz::tui { + +class TuiPanel; + +class TuiColor { +public: + enum Kind { None, Int, Str, Rgb }; + TuiColor() = default; + TuiColor(std::nullptr_t) {} + TuiColor(int n) : kind(Int), n(n) {} + TuiColor(const char* s) : kind(Str), s(s) {} + TuiColor(const std::string& s) : kind(Str), s(s) {} + static TuiColor rgb(int r, int g, int b) { TuiColor c; c.kind = Rgb; c.c = {r, g, b}; return c; } + bool is_none() const { return kind == None; } + bool truthy() const; // python truthiness of the color object + bool operator==(const TuiColor& o) const; + bool operator!=(const TuiColor& o) const { return !(*this == o); } + Kind kind{None}; + int n{0}; + std::string s; + std::array c{{0, 0, 0}}; +}; + +// The hashable identity of a look (TuiStyle.key()). +struct TuiStyleKey { + TuiColor fg, bg; + bool bold{false}, dim{false}, underline{false}, reverse{false}, strike{false}; + bool operator==(const TuiStyleKey& o) const { + return fg == o.fg && bg == o.bg && bold == o.bold && dim == o.dim && underline == o.underline && reverse == o.reverse && strike == o.strike; + } + bool operator!=(const TuiStyleKey& o) const { return !(*this == o); } +}; + +class TuiWatched { +public: + virtual ~TuiWatched(); + // Register a panel that paints from this object (weak: never keeps it alive). + void adopt(TuiPanel* panel); + void disown(TuiPanel* panel); +protected: + void changed(); // touch every owner + std::vector owners_; +}; + +class TuiStyle : public TuiWatched { +public: + TuiStyle(TuiColor fg = TuiColor(), TuiColor bg = TuiColor(), bool bold = false, bool dim = false, + bool underline = false, bool reverse = false, bool strike = false); + TuiStyle(const TuiStyle& o); + TuiStyle& operator=(const TuiStyle& o); + + const TuiColor& fg() const { return fg_; } + const TuiColor& bg() const { return bg_; } + bool bold() const { return bold_; } + bool dim() const { return dim_; } + bool underline() const { return underline_; } + bool reverse() const { return reverse_; } + bool strike() const { return strike_; } + void set_fg(const TuiColor& v); + void set_bg(const TuiColor& v); + void set_bold(bool v); + void set_dim(bool v); + void set_underline(bool v); + void set_reverse(bool v); + void set_strike(bool v); + + const TuiStyleKey& key() const; // cached; any field write drops the cache + std::string sgr() const; + std::shared_ptr merged(const TuiStyle& other) const; // always a new style (callers never pass None) + static std::string sgr_from_key(const TuiStyleKey& key); + static std::string color_sgr(const TuiColor& color, bool background); + +private: + TuiColor fg_, bg_; + bool bold_, dim_, underline_, reverse_, strike_; + mutable bool have_key_{false}; + mutable TuiStyleKey key_; + void invalidate() { have_key_ = false; changed(); } +}; +using StyleP = std::shared_ptr; + +// Convenience constructor: mk(fg, bg, bold, ...) -> shared style. +inline StyleP mk_style(TuiColor fg = TuiColor(), TuiColor bg = TuiColor(), bool bold = false, bool dim = false, + bool underline = false, bool reverse = false, bool strike = false) { + return std::make_shared(fg, bg, bold, dim, underline, reverse, strike); +} + +// One styled text run: the compositor's paint primitive. +struct TuiSpan { + UStr text; + StyleP style; + TuiSpan() : style(std::make_shared()) {} + TuiSpan(const UStr& text, StyleP style = nullptr) : text(text), style(style ? style : std::make_shared()) {} + TuiSpan(const char32_t* text, StyleP style = nullptr) : text(text), style(style ? style : std::make_shared()) {} +}; +using SpanRow = std::vector; +using SpanRows = std::vector; + +// Static helpers over spans (tui_flatten, tui_runs, tui_pad_spans, tui_span_width, tui_slice_spans). +class TuiSpans { +public: + using Cells = std::vector>; + static Cells flatten(const SpanRow& spans); + static SpanRow runs(const Cells& cells); + static SpanRow pad(const SpanRow& spans, long width, const StyleP& fill_style); + static long width(const SpanRow& spans); + static SpanRow slice(const SpanRow& spans, long start, long length, const StyleP& fill_style); + static UStr plain(const SpanRow& spans); +}; + +} // namespace detz::tui diff --git a/taicpp/src/tui/TuiTerminal.cpp b/taicpp/src/tui/TuiTerminal.cpp new file mode 100644 index 0000000..895e915 --- /dev/null +++ b/taicpp/src/tui/TuiTerminal.cpp @@ -0,0 +1,217 @@ +#include "TuiTerminal.h" +#include "TuiApp.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +namespace detz::tui { + +const std::map& TuiTerminal::term_keys() { + static const std::map m = { + {"enter", "\r"}, {"tab", "\t"}, {"backtab", "\x1b[Z"}, {"backspace", "\x7f"}, {"escape", "\x1b"}, + {"up", "\x1b[A"}, {"down", "\x1b[B"}, {"right", "\x1b[C"}, {"left", "\x1b[D"}, + {"home", "\x1b[H"}, {"end", "\x1b[F"}, {"pageup", "\x1b[5~"}, {"pagedown", "\x1b[6~"}, + {"delete", "\x1b[3~"}, {"insert", "\x1b[2~"}, {"f1", "\x1bOP"}, {"f2", "\x1bOQ"}, + {"f3", "\x1bOR"}, {"f4", "\x1bOS"}, {"f5", "\x1b[15~"}, {"f6", "\x1b[17~"}, + {"f7", "\x1b[18~"}, {"f8", "\x1b[19~"}, {"f9", "\x1b[20~"}, {"f10", "\x1b[21~"}, + {"f11", "\x1b[23~"}, {"f12", "\x1b[24~"}}; + return m; +} + +TuiTerminal::TuiTerminal(std::vector cmd_in, bool interactive_flag, TuiPanelOpts opts) + : TuiPanel([&] { + if (!opts.focusable) opts.focusable = interactive_flag; + if (!opts.border) opts.border = true; + return opts; + }()), + interactive(interactive_flag), screen(24, 80) { + if (cmd_in.empty()) { + const char* sh = std::getenv("SHELL"); + cmd = {sh && *sh ? sh : "/bin/sh"}; + } else { + cmd = std::move(cmd_in); + } +} + +TuiTerminal::~TuiTerminal() { + if (fd >= 0) ::close(fd); +} + +std::pair TuiTerminal::measure() { return {static_cast(screen.cols), static_cast(screen.rows)}; } + +void TuiTerminal::arrange(int x, int y, int w, int h, TuiRect clip_in) { + TuiPanel::arrange(x, y, w, h, clip_in); + int width = content_w(), height = content_h(); + if (width != screen.cols || height != screen.rows) { + screen.resize(std::max(1, height), std::max(1, width)); + apply_winsize(); + } +} + +bool TuiTerminal::spawn() { + if (pid >= 0) return false; + int master = -1; + pid_t child = forkpty(&master, nullptr, nullptr, nullptr); + if (child < 0) { + error = "pty not available on this platform"; + return false; + } + if (child == 0) { + if (!std::getenv("TERM")) setenv("TERM", "xterm-256color", 0); + std::vector argv; + for (auto& a : cmd) argv.push_back(const_cast(a.c_str())); + argv.push_back(nullptr); + execvp(argv[0], argv.data()); + std::string msg = "tui terminal: cannot exec " + cmd[0] + ": " + std::strerror(errno) + "\n"; + (void)!::write(2, msg.data(), msg.size()); + _exit(127); + } + pid = child; + fd = master; + apply_winsize(); + return true; +} + +void TuiTerminal::apply_winsize() { + if (fd < 0) return; + struct winsize ws{}; + ws.ws_row = static_cast(screen.rows); + ws.ws_col = static_cast(screen.cols); + ioctl(fd, TIOCSWINSZ, &ws); +} + +std::vector TuiTerminal::fds() const { + if (fd >= 0 && !exit_status) return {fd}; + return {}; +} + +bool TuiTerminal::feed_output() { + if (fd < 0) return false; + char buf[65536]; + ssize_t n = ::read(fd, buf, sizeof buf); + if (n <= 0) { + poll_exit(); + return false; + } + screen.feed(std::string(buf, static_cast(n))); + if (app) touch(); + return true; +} + +void TuiTerminal::drain_pending() { + while (!pending.empty() && fd >= 0 && !exit_status) { + size_t n = std::min(pending.size(), 4096); + ssize_t written = ::write(fd, pending.data(), n); + if (written <= 0) break; + pending.erase(0, static_cast(written)); + } +} + +std::optional TuiTerminal::poll_exit() { + if (pid < 0 || exit_status) return exit_status; + int status = 0; + pid_t done = ::waitpid(static_cast(pid), &status, WNOHANG); + if (done < 0) { done = 1; status = 0; } + if (done) { + exit_status = status; + if (fd >= 0) ::close(fd); + fd = -1; + if (app) touch(); + } + return exit_status; +} + +void TuiTerminal::terminate() { + if (pid < 0 || exit_status) return; + if (::killpg(static_cast(pid), SIGKILL) != 0) ::kill(static_cast(pid), SIGKILL); + int status = 0; + if (::waitpid(static_cast(pid), &status, 0) >= 0) exit_status = status; + else exit_status = 0; + if (fd >= 0) ::close(fd); + fd = -1; +} + +SpanRows TuiTerminal::content_rows() { + int width = content_w(), height = content_h(); + if (!error.empty()) { + SpanRows rows; + rows.push_back(SpanRow{TuiSpan(TuiStr::ljust(TuiStr::head(TuiStr::u(error), width), width), style()->merged(TuiStyle("red")))}); + while (static_cast(rows.size()) < height) rows.push_back(SpanRow{TuiSpan(UStr(static_cast(width), U' '), style())}); + return rows; + } + bool show_cursor = app && app->focused == this && screen.cursor_visible && !exit_status; + SpanRows rows; + for (int row = 0; row < std::min(height, screen.rows); row++) { + const TuiVtRow& grid_row = screen.grid[static_cast(row)]; + long cursor_x = (show_cursor && row == screen.cy) ? screen.cx : -1; + TuiSpans::Cells cells; + for (int col = 0; col < std::min(width, screen.cols); col++) { + const TuiVtCell& cell = grid_row[static_cast(col)]; + char32_t ch = cell.set ? cell.ch : U' '; + StyleP st = TuiVtScreen::style_for(cell, *style()); + if (col == cursor_x) st = st->merged(TuiStyle(TuiColor(), TuiColor(), false, false, false, true)); + cells.emplace_back(ch, st); + } + while (static_cast(cells.size()) < width) cells.emplace_back(U' ', style()); + rows.push_back(TuiSpans::runs(cells)); + } + while (static_cast(rows.size()) < height) rows.push_back(SpanRow{TuiSpan(UStr(static_cast(width), U' '), style())}); + return rows; +} + +void TuiTerminal::on_click(TuiClick& event) { + if (!event.pressed || !interactive) return; + event.stop(); + focus(); +} + +void TuiTerminal::on_key(TuiKey& event) { + if (!interactive || fd < 0 || exit_status) return; + std::string data = key_bytes(event); + if (!data.empty()) { + event.stop(); + pending += data; + } +} + +void TuiTerminal::send_text(const UStr& text) { pending += TuiStr::s(text); } + +void TuiTerminal::on_paste(TuiPaste& event) { + if (!interactive || exit_status) return; + event.stop(); + pending += TuiStr::s(event.text); +} + +std::string TuiTerminal::key_bytes(const TuiKey& event) { + const std::string& key = event.key; + auto it = term_keys().find(key); + if (it != term_keys().end() && event.modifiers.empty()) return it->second; + if (key.compare(0, 5, "ctrl+") == 0 && TuiStr::u(key).size() == 6) { + UStr rest = TuiStr::upper(TuiStr::from(TuiStr::u(key), 5)); + long v = static_cast(rest[0]) - 0x40; + if (v < 0 || v > 255) throw std::out_of_range("bytes must be in range(0, 256)"); + return std::string(1, static_cast(v)); + } + if (key.compare(0, 4, "alt+") == 0 && event.ch.size() == 1) return "\x1b" + TuiStr::s(event.ch); + size_t plus = key.rfind('+'); + if (plus != std::string::npos) { + std::string mods = key.substr(0, plus), base = key.substr(plus + 1); + static const std::map finals = {{"up", 'A'}, {"down", 'B'}, {"right", 'C'}, {"left", 'D'}, {"home", 'H'}, {"end", 'F'}}; + auto f = finals.find(base); + if (f != finals.end()) { + int code = 1 + (mods.find("shift") != std::string::npos) + (mods.find("alt") != std::string::npos) * 2 + (mods.find("ctrl") != std::string::npos) * 4; + return "\x1b[1;" + std::to_string(code) + std::string(1, f->second); + } + return ""; + } + if (!event.ch.empty()) return TuiStr::s(event.ch); + return ""; +} + +} // namespace detz::tui diff --git a/taicpp/src/tui/TuiTerminal.h b/taicpp/src/tui/TuiTerminal.h new file mode 100644 index 0000000..222576a --- /dev/null +++ b/taicpp/src/tui/TuiTerminal.h @@ -0,0 +1,41 @@ +#pragma once +// TuiTerminal: a live sub-terminal; a real pty child streamed through a VT +// screen (tai.py TuiTerminal, TUI_TERM_KEYS). +#include "TuiPanel.h" +#include "TuiVtScreen.h" +#include +namespace detz::tui { + +class TuiTerminal : public TuiPanel { +public: + explicit TuiTerminal(std::vector cmd = {}, bool interactive = true, TuiPanelOpts opts = {}); + ~TuiTerminal() override; + std::vector cmd; + bool interactive; + TuiVtScreen screen; + long pid{-1}; // python None + int fd{-1}; // python None + std::optional exit_status; // raw wait status + std::string error; + std::string pending; // bytes queued for the child + + bool memoize_measure() const override { return false; } + std::pair measure() override; + void arrange(int x, int y, int w, int h, TuiRect clip) override; + bool spawn(); + void apply_winsize(); + std::vector fds() const; + bool feed_output(); + void drain_pending(); + std::optional poll_exit(); + void terminate(); + SpanRows content_rows() override; + void on_click(TuiClick& event) override; + void on_key(TuiKey& event) override; + void send_text(const UStr& text); + void on_paste(TuiPaste& event) override; + static std::string key_bytes(const TuiKey& event); + static const std::map& term_keys(); // TUI_TERM_KEYS +}; + +} // namespace detz::tui diff --git a/taicpp/src/tui/TuiText.cpp b/taicpp/src/tui/TuiText.cpp new file mode 100644 index 0000000..2e00775 --- /dev/null +++ b/taicpp/src/tui/TuiText.cpp @@ -0,0 +1,60 @@ +#include "TuiText.h" +#include +namespace detz::tui { + +TuiText::TuiText(UStr text, TuiPanelOpts opts, std::string text_align, bool wrap) + : TuiPanel(std::move(opts)), text_(std::move(text)), text_align_(std::move(text_align)), wrap_(wrap) {} + +void TuiText::set_text(const UStr& v) { bool same = text_ == v; text_ = v; if (app && !same) touch(); } +void TuiText::set_text_align(const std::string& v) { bool same = text_align_ == v; text_align_ = v; if (app && !same) touch(); } +void TuiText::set_wrap(bool v) { bool same = wrap_ == v; wrap_ = v; if (app && !same) touch(); } + +std::pair TuiText::measure() { + auto lines = TuiStr::split(text_, U'\n'); + int width = 0; + for (auto& line : lines) width = std::max(width, static_cast(line.size())); + return {width, std::max(1, static_cast(lines.size()))}; +} + +std::vector TuiText::lines(int width) const { + std::vector out; + for (auto& para : TuiStr::split(text_, U'\n')) { + if (!wrap_ || width <= 0) { + out.push_back(para); + continue; + } + UStr line; + for (auto& word : TuiStr::split(para, U' ')) { + UStr piece = line.empty() ? word : line + U" " + word; + if (static_cast(piece.size()) <= width || line.empty()) { + line = piece; + } else { + out.push_back(line); + line = word; + } + } + out.push_back(line); + } + return out; +} + +SpanRows TuiText::content_rows() { + int width = content_w(), height = content_h(); + auto ls = lines(width); + int n = static_cast(ls.size()); + int top; + if (valign() == "middle" || valign() == "center") top = std::max(0, (height - n) / 2); + else if (valign() == "bottom") top = std::max(0, height - n); + else top = 0; + SpanRows rows; + for (int pos = 0; pos < height; pos++) { + UStr line = (top <= pos && pos < top + n) ? ls[static_cast(pos - top)] : UStr(); + if (text_align_ == "center") line = TuiStr::center(line, width); + else if (text_align_ == "right") line = TuiStr::rjust(line, width); + else line = TuiStr::ljust(line, width); + rows.push_back(SpanRow{TuiSpan(TuiStr::head(line, width), style())}); + } + return rows; +} + +} // namespace detz::tui diff --git a/taicpp/src/tui/TuiText.h b/taicpp/src/tui/TuiText.h new file mode 100644 index 0000000..08b9292 --- /dev/null +++ b/taicpp/src/tui/TuiText.h @@ -0,0 +1,24 @@ +#pragma once +// TuiText: wrapped static text with alignment (tai.py TuiText). +#include "TuiPanel.h" +namespace detz::tui { + +class TuiText : public TuiPanel { +public: + explicit TuiText(UStr text = UStr(), TuiPanelOpts opts = {}, std::string text_align = "left", bool wrap = true); + const UStr& text() const { return text_; } + const std::string& text_align() const { return text_align_; } + bool wrap() const { return wrap_; } + void set_text(const UStr& v); + void set_text_align(const std::string& v); + void set_wrap(bool v); + std::pair measure() override; + std::vector lines(int width) const; + SpanRows content_rows() override; +private: + UStr text_; + std::string text_align_; + bool wrap_; +}; + +} // namespace detz::tui diff --git a/taicpp/src/tui/TuiTextBox.cpp b/taicpp/src/tui/TuiTextBox.cpp new file mode 100644 index 0000000..c217017 --- /dev/null +++ b/taicpp/src/tui/TuiTextBox.cpp @@ -0,0 +1,397 @@ +#include "TuiTextBox.h" +#include "TuiApp.h" +#include "TuiComplete.h" +#include +namespace detz::tui { + +TuiTextBox::TuiTextBox(UStr text, CompleteFn complete, CompleteFn mentions, bool send_on_enter, std::function on_send, + long max_chars, UStr placeholder, std::shared_ptr history, TuiPanelOpts opts) + : TuiPanel([&] { + if (!opts.focusable) opts.focusable = true; + if (!opts.border) opts.border = true; + return opts; + }()), + complete_fn(std::move(complete)), mention_fn(std::move(mentions)), send_on_enter(send_on_enter), on_send(std::move(on_send)), + max_chars(std::max(0L, max_chars)), placeholder(std::move(placeholder)), history(std::move(history)), rows_seen_(0) { + lines = TuiStr::split(text, U'\n'); + if (lines.empty()) lines = {UStr()}; + cy_ = static_cast(lines.size()) - 1; + cx_ = static_cast(lines.back().size()); + top_ = 0; + rows_seen_ = static_cast(lines.size()); +} + +void TuiTextBox::set_cx(long v) { bool same = cx_ == v; cx_ = v; if (app && !same) touch(); } +void TuiTextBox::set_cy(long v) { bool same = cy_ == v; cy_ = v; if (app && !same) touch(); } +void TuiTextBox::set_top(long v) { bool same = top_ == v; top_ = v; if (app && !same) touch(); } +void TuiTextBox::set_lines(std::vector v) { + lines = std::move(v); + touch(); +} +void TuiTextBox::reset_cursor_home() { + set_cx(0); + set_cy(0); + set_top(0); +} + +void TuiTextBox::touch() { + TuiPanel::touch(); + long rows = static_cast(lines.size()); + if (rows != rows_seen_ && app) { + rows_seen_ = rows; + std::weak_ptr weak = weak_from_this(); + app->run_on_loop([weak, rows] { + if (auto p = weak.lock()) { + TuiChange ev; + ev.panel = p.get(); + ev.rows = rows; + p->emit(ev); + } + }); + } +} + +UStr TuiTextBox::value() const { return TuiStr::join(lines, U"\n"); } + +std::pair TuiTextBox::measure() { + int width = 0; + for (auto& l : lines) width = std::max(width, static_cast(l.size())); + return {std::max(1, width), std::max(1, static_cast(lines.size()))}; +} + +void TuiTextBox::clamp() { + set_cy(std::max(0L, std::min(static_cast(lines.size()) - 1, cy_))); + set_cx(std::max(0L, std::min(static_cast(lines[static_cast(cy_)].size()), cx_))); +} + +void TuiTextBox::touch_if_mounted() { if (app) touch(); } + +std::pair TuiTextBox::origin() const { + int edge = border() ? 1 : 0; + return {ax + edge + padding()[3], ay + edge + padding()[0]}; +} + +SpanRows TuiTextBox::content_rows() { + int width = content_w(), height = content_h(); + clamp(); + if (cy_ < top_) set_top(cy_); + if (cy_ >= top_ + height) set_top(cy_ - height + 1); + long left = std::max(0L, cx_ - width + 1); + bool focused = app && app->focused == this && enabled(); + UStr val = value(); + UStr hint = (!placeholder.empty() && TuiStr::strip(val).empty()) ? placeholder : UStr(); + StyleP hint_style = !hint.empty() ? style()->merged(TuiStyle(TuiColor("role:muted"), TuiColor(), false, true)) : style(); + SpanRows rows; + for (int pos = 0; pos < height; pos++) { + long index = top_ + pos; + UStr line = (0 <= index && index < static_cast(lines.size())) ? TuiStr::slice(lines[static_cast(index)], left, left + width) : UStr(); + StyleP st = style(); + if (!hint.empty() && index == 0) { + line = hint; + st = hint_style; + } + line = TuiStr::ljust(line, width); + if (focused && index == cy_) { + long col = cx_ - left; + UStr before = TuiStr::slice(line, 0, col), at = TuiStr::slice(line, col, col + 1), after = TuiStr::from(line, col + 1); + rows.push_back(SpanRow{TuiSpan(before, st), TuiSpan(at.empty() ? UStr(U" ") : at, st->merged(TuiStyle(TuiColor(), TuiColor(), false, false, false, true))), + TuiSpan(after, st)}); + } else { + rows.push_back(SpanRow{TuiSpan(TuiStr::head(line, width), st)}); + } + } + return rows; +} + +void TuiTextBox::insert(UStr text) { + if (max_chars) { + long room = max_chars - static_cast(value().size()); + if (room <= 0) return; + text = TuiStr::head(text, room); + } + auto parts = TuiStr::split(text, U'\n'); + UStr line = lines[static_cast(cy_)]; + UStr head = TuiStr::head(line, cx_), tail = TuiStr::from(line, cx_); + lines[static_cast(cy_)] = head + parts[0]; + touch(); + set_cx(static_cast(head.size() + parts[0].size())); + for (size_t i = 1; i + 1 < parts.size(); i++) { + set_cy(cy_ + 1); + lines.insert(lines.begin() + cy_, parts[i]); + touch(); + } + if (parts.size() > 1) { + set_cy(cy_ + 1); + lines.insert(lines.begin() + cy_, parts.back() + tail); + touch(); + set_cx(static_cast(parts.back().size())); + } else { + lines[static_cast(cy_)] += tail; + touch(); + } + clamp(); + touch_if_mounted(); +} + +void TuiTextBox::complete_refresh() { + std::shared_ptr popup = complete_popup; + if (!app) { + if (popup) complete_hide(); + return; + } + auto match = TuiComplete::word_match(TuiStr::head(lines[static_cast(cy_)], cx_)); + CompleteFn* fn = nullptr; + if (match) fn = match->trigger == U'/' ? &complete_fn : &mention_fn; + std::vector items; + try { + if (match && fn && *fn) items = (*fn)(match->word); + } catch (const std::exception&) { + items.clear(); + } + if (items.empty()) { + if (popup) complete_hide(); + return; + } + if (!popup) { + popup = std::make_shared(); + std::weak_ptr weak = weak_from_this(); + popup->set_handler("select", [weak](TuiEvent& e) { + if (auto p = weak.lock()) static_cast(p.get())->complete_picked(static_cast(e)); + }); + complete_popup = popup; + root()->add(popup); + } + popup->set_items(items); + complete_place(*popup); +} + +void TuiTextBox::complete_place(TuiAutocomplete& popup) { + TuiPanel* r = root(); + int edge = r->border() ? 1 : 0; + int ox = r->ax + edge + r->padding()[3]; + int oy = r->ay + edge + r->padding()[0]; + int room_w = std::max(0, r->content_w()); + int room_h = std::max(0, r->content_h()); + int cw = popup.chrome_w(); + int ch = popup.chrome_h(); + int width = 4; + for (auto& o : popup.options) width = std::max(width, static_cast(o.label.size()) + 4); + width = std::max(4, std::min(width, std::max(4, room_w - cw))); + constexpr int TUI_COMPLETE_MAXH = 8; + int height = std::max(1, std::min(static_cast(popup.options.size()), TUI_COMPLETE_MAXH)); + int view_h = content_h(); + if (cy_ < top_) set_top(cy_); + else if (cy_ >= top_ + view_h) set_top(cy_ - view_h + 1); + auto bo = origin(); + long left = std::max(0L, cx_ - content_w() + 1); + long cur_x = bo.first + (cx_ - left); + long cur_y = bo.second + (cy_ - top_); + popup.set_x(TuiSpec(static_cast(std::min(std::max(0L, cur_x - ox), static_cast(std::max(0, room_w - width - cw)))))); + long below = cur_y - oy + 1; + if (below + height + ch <= room_h) popup.set_y(TuiSpec(below)); + else popup.set_y(TuiSpec(std::max(0L, cur_y - oy - height - ch))); + popup.set_w(TuiSpec(width)); + popup.set_h(TuiSpec(height)); +} + +void TuiTextBox::complete_move(const std::string& key) { + auto popup = complete_popup; + if (!popup) return; + long n = static_cast(popup->options.size()); + if (key == "up") popup->move(-1); + else if (key == "down") popup->move(1); + else if (key == "home") popup->move(-n); + else if (key == "end") popup->move(n); + else if (key == "pageup") popup->move(-std::max(1, popup->content_h())); + else if (key == "pagedown") popup->move(std::max(1, popup->content_h())); +} + +void TuiTextBox::complete_hide() { + auto popup = complete_popup; + complete_popup.reset(); + if (popup && popup->parent) popup->parent->remove(popup.get()); +} + +void TuiTextBox::complete_picked(TuiSelect& event) { + if (event.panel != complete_popup.get()) return; + event.stop(); + complete_accept(event.value); +} + +void TuiTextBox::complete_accept(const TuiValue& value) { + UStr line = lines[static_cast(cy_)]; + auto match = TuiComplete::word_match(TuiStr::head(line, cx_)); + if (!match) { + complete_hide(); + return; + } + char32_t trigger = match->trigger; + long word_start = static_cast(match->trigger_pos); + UStr chars = trigger == U'/' ? UStr(U"_-") : UStr(U"_-./"); + long end = cx_; + while (end < static_cast(line.size()) && (TuiStr::isalnum(line[static_cast(end)]) || chars.find(line[static_cast(end)]) != UStr::npos)) end++; + UStr replacement = UStr(1, trigger) + TuiStr::u(value.str()); + lines[static_cast(cy_)] = TuiStr::head(line, word_start) + replacement + TuiStr::from(line, end); + touch(); + set_cx(word_start + static_cast(replacement.size())); + if (cx_ >= static_cast(lines[static_cast(cy_)].size())) { + lines[static_cast(cy_)] += U" "; + touch(); + set_cx(cx_ + 1); + } + clamp(); + touch_if_mounted(); + complete_hide(); +} + +void TuiTextBox::on_paste(TuiPaste& event) { + if (!enabled()) return; + event.stop(); + UStr text = TuiStr::replace(TuiStr::replace(event.text, U"\r\n", U"\n"), U"\r", U"\n"); + if (!text.empty()) { + insert(text); + complete_refresh(); + } +} + +void TuiTextBox::recall(const std::optional& text) { + if (!text) return; + auto ls = TuiStr::split(TuiStr::u(*text), U'\n'); + if (ls.empty()) ls = {UStr()}; + set_lines(ls); + set_cy(static_cast(lines.size()) - 1); + set_cx(static_cast(lines.back().size())); + set_top(0); + complete_hide(); + clamp(); + touch_if_mounted(); +} + +void TuiTextBox::on_key(TuiKey& event) { + if (!enabled()) return; + const std::string& key = event.key; + if (key == "escape" && complete_popup) { + event.stop(); + complete_hide(); + return; + } + if (key == "escape" || key == "tab" || key == "backtab") return; + if (complete_popup) { + if (key == "up" || key == "down" || key == "home" || key == "end" || key == "pageup" || key == "pagedown") { + event.stop(); + complete_move(key); + return; + } + if (key == "enter") { + event.stop(); + complete_popup->choose(); + return; + } + } + if (key == "left") { + event.stop(); + set_cx(cx_ - 1); + } else if (key == "right") { + event.stop(); + set_cx(cx_ + 1); + } else if (key == "up") { + event.stop(); + if (history && cy_ == 0) { + recall(history->older(TuiStr::s(value()))); + return; + } + set_cy(cy_ - 1); + } else if (key == "down") { + event.stop(); + if (history && cy_ == static_cast(lines.size()) - 1 && history->pos) { + recall(history->newer()); + return; + } + set_cy(cy_ + 1); + } else if (key == "home") { + event.stop(); + set_cx(0); + } else if (key == "end") { + event.stop(); + set_cx(static_cast(lines[static_cast(cy_)].size())); + } else if (key == "pageup" || key == "pagedown") { + event.stop(); + long step = std::max(1, content_h()); + set_cy(cy_ + (key == "pageup" ? -step : step)); + } else if (key == "enter") { + event.stop(); + if (send_on_enter && on_send) { + complete_hide(); + on_send(value()); + return; + } + insert(U"\n"); + complete_refresh(); + return; + } else if (key == "shift+enter" || key == "alt+enter") { + event.stop(); + insert(U"\n"); + complete_refresh(); + return; + } else if (key == "backspace") { + event.stop(); + if (cx_ > 0) { + UStr line = lines[static_cast(cy_)]; + lines[static_cast(cy_)] = TuiStr::head(line, cx_ - 1) + TuiStr::from(line, cx_); + touch(); + set_cx(cx_ - 1); + } else if (cy_ > 0) { + UStr prev = lines[static_cast(cy_ - 1)]; + set_cx(static_cast(prev.size())); + lines[static_cast(cy_ - 1)] = prev + lines[static_cast(cy_)]; + touch(); + lines.erase(lines.begin() + cy_); + touch(); + set_cy(cy_ - 1); + } + } else if (key == "delete") { + event.stop(); + UStr line = lines[static_cast(cy_)]; + if (cx_ < static_cast(line.size())) { + lines[static_cast(cy_)] = TuiStr::head(line, cx_) + TuiStr::from(line, cx_ + 1); + touch(); + } else if (cy_ < static_cast(lines.size()) - 1) { + lines[static_cast(cy_)] = line + lines[static_cast(cy_ + 1)]; + touch(); + lines.erase(lines.begin() + cy_ + 1); + touch(); + } + } else { + const UStr& ch = event.ch; + bool has_plus = key.find('+') != std::string::npos; + bool prefixed = key.compare(0, 5, "ctrl+") == 0 || key.compare(0, 4, "alt+") == 0; + if (!ch.empty() && !has_plus && !prefixed) { + event.stop(); + insert(ch); + complete_refresh(); + return; + } + return; + } + clamp(); + touch_if_mounted(); + complete_refresh(); +} + +void TuiTextBox::on_click(TuiClick& event) { + if (!enabled() || !event.pressed) return; + event.stop(); + focus(); + auto o = origin(); + long left = std::max(0L, cx_ - content_w() + 1); + set_cy(top_ + (event.y - o.second)); + set_cx(left + (event.x - o.first)); + clamp(); + touch_if_mounted(); + complete_refresh(); +} + +void TuiTextBox::on_blur(TuiBlur&) { complete_hide(); } +void TuiTextBox::on_unmount(TuiUnmount&) { complete_hide(); } + +} // namespace detz::tui diff --git a/taicpp/src/tui/TuiTextBox.h b/taicpp/src/tui/TuiTextBox.h new file mode 100644 index 0000000..bc33251 --- /dev/null +++ b/taicpp/src/tui/TuiTextBox.h @@ -0,0 +1,63 @@ +#pragma once +// TuiTextBox: multiline text editor (tai.py TuiTextBox). Arrows/home/end/ +// pgup/pgdn move, Enter splits lines, Backspace/Delete join them, clicks +// position the cursor. Escape is left alone so dialogs can close on it, unless +// a completion popup is open: then Esc dismisses the popup first. complete= +// wires a prefix-to-items callable for /words and mentions= the same for +// @words, both driving a TuiAutocomplete popup. +#include "TuiAutocomplete.h" +#include "TuiHistory.h" +#include "TuiPanel.h" +namespace detz::tui { + +class TuiTextBox : public TuiPanel { +public: + using CompleteFn = std::function(const UStr& prefix)>; + explicit TuiTextBox(UStr text = UStr(), CompleteFn complete = nullptr, CompleteFn mentions = nullptr, bool send_on_enter = false, + std::function on_send = nullptr, long max_chars = 0, UStr placeholder = UStr(), + std::shared_ptr history = nullptr, TuiPanelOpts opts = {}); + + std::vector lines; // mutate through set_lines()/the editing methods (each touches) + long cx() const { return cx_; } + long cy() const { return cy_; } + long top() const { return top_; } + void set_cx(long v); + void set_cy(long v); + void set_top(long v); + void set_lines(std::vector v); // python: self.lines = [...] + void reset_cursor_home(); // cx = cy = top = 0 (the chat composer clear) + CompleteFn complete_fn, mention_fn; + std::shared_ptr complete_popup; + bool send_on_enter; + std::function on_send; + long max_chars; + UStr placeholder; + std::shared_ptr history; + + UStr value() const; + void touch() override; + std::pair measure() override; + SpanRows content_rows() override; + void insert(UStr text); + void on_paste(TuiPaste& event) override; + void on_key(TuiKey& event) override; + void on_click(TuiClick& event) override; + void on_blur(TuiBlur& event) override; + void on_unmount(TuiUnmount& event) override; + void clamp(); + void touch_if_mounted(); // python _touch + void complete_refresh(); + void complete_hide(); + +private: + long cx_{0}, cy_{0}, top_{0}; + long rows_seen_; + std::pair origin() const; + void complete_place(TuiAutocomplete& popup); + void complete_move(const std::string& key); + void complete_picked(TuiSelect& event); + void complete_accept(const TuiValue& value); + void recall(const std::optional& text); +}; + +} // namespace detz::tui diff --git a/taicpp/src/tui/TuiTheme.cpp b/taicpp/src/tui/TuiTheme.cpp new file mode 100644 index 0000000..5be4cb9 --- /dev/null +++ b/taicpp/src/tui/TuiTheme.cpp @@ -0,0 +1,133 @@ +#include "TuiTheme.h" +#include +#include +namespace detz::tui { + +namespace { +std::vector>>& registry() { + static std::vector>> r; + return r; +} +std::shared_ptr& active() { static std::shared_ptr a; return a; } +bool& ready() { static bool r = false; return r; } +std::string join_sorted(std::vector names) { + std::sort(names.begin(), names.end()); + std::string o; + for (size_t i = 0; i < names.size(); i++) { if (i) o += ", "; o += names[i]; } + return o; +} +} // namespace + +TuiTheme::TuiTheme(std::string n, bool d, std::vector> r) : name(std::move(n)), dark(d) { + for (auto& p : r) roles[p.first] = p.second; +} +TuiColor TuiTheme::color(const std::string& role) const { + auto it = roles.find(role); + return it == roles.end() ? TuiColor() : it->second; +} + +const std::vector& TuiThemes::role_names() { + static const std::vector r = {"bg", "surface", "fg", "muted", "border", "accent", "on_accent", "selection", "success", "warning", "error", "info", + "bubble_in", "bubble_out", "bubble_fg", "meta_in", "meta_out", "tick_read", + "service_bg", "service_fg", "unread", "unread_muted", + "panel", "accent2", "accent3", "code_bg", "tool_bg"}; + return r; +} +const char* TuiThemes::default_name() { return "dark"; } + +std::shared_ptr TuiThemes::register_theme(std::shared_ptr theme) { + std::string missing; + for (auto& role : role_names()) { + if (!theme->color(role).truthy()) { if (!missing.empty()) missing += ", "; missing += role; } + } + if (!missing.empty()) throw std::invalid_argument("theme '" + theme->name + "' lacks roles: " + missing); + for (auto& p : registry()) if (p.first == theme->name) { p.second = theme; return theme; } + registry().emplace_back(theme->name, theme); + return theme; +} + +void TuiThemes::init() { + if (ready()) return; + ready() = true; + auto T = [](const char* name, bool dark, std::initializer_list> kv) { + std::vector> roles; + for (auto& p : kv) roles.emplace_back(p.first, TuiColor(p.second)); + register_theme(std::make_shared(name, dark, roles)); + }; + T("dark", true, { + {"bg", "#0a0e1a"}, {"surface", "#141b2e"}, {"fg", "#e5e9f5"}, {"muted", "#7f8aa8"}, + {"border", "#2a3558"}, {"accent", "#8b5cf6"}, {"on_accent", "#ffffff"}, {"selection", "#22305f"}, + {"success", "#34d399"}, {"warning", "#fbbf24"}, {"error", "#fb7185"}, {"info", "#38bdf8"}, + {"bubble_in", "#18223d"}, {"bubble_out", "#4338ca"}, {"bubble_fg", "#f1f5ff"}, + {"meta_in", "#6b7aa3"}, {"meta_out", "#c7d2fe"}, {"tick_read", "#67e8f9"}, + {"service_bg", "#1d2750"}, {"service_fg", "#a5b4fc"}, {"unread", "#8b5cf6"}, {"unread_muted", "#3a4668"}, + {"panel", "#0e1424"}, {"accent2", "#22d3ee"}, {"accent3", "#f472b6"}, {"code_bg", "#0b1226"}, {"tool_bg", "#101a33"}}); + T("light", false, { + {"bg", "#f5f7ff"}, {"surface", "#ffffff"}, {"fg", "#1b2036"}, {"muted", "#6a7494"}, + {"border", "#cdd5f0"}, {"accent", "#6d4aff"}, {"on_accent", "#ffffff"}, {"selection", "#dcd6ff"}, + {"success", "#0f9d6b"}, {"warning", "#b7791f"}, {"error", "#d6336c"}, {"info", "#0b7fc2"}, + {"bubble_in", "#ffffff"}, {"bubble_out", "#dcd6ff"}, {"bubble_fg", "#1b2036"}, + {"meta_in", "#8a93b2"}, {"meta_out", "#6b5bd6"}, {"tick_read", "#0b7fc2"}, + {"service_bg", "#dfe4fa"}, {"service_fg", "#4a55a0"}, {"unread", "#6d4aff"}, {"unread_muted", "#b9c0dd"}, + {"panel", "#eaeefc"}, {"accent2", "#0891b2"}, {"accent3", "#db2777"}, {"code_bg", "#eef1fd"}, {"tool_bg", "#e3e8fb"}}); + T("nord", true, { + {"bg", "#2e3440"}, {"surface", "#3b4252"}, {"fg", "#eceff4"}, {"muted", "#7c8698"}, + {"border", "#4c566a"}, {"accent", "#88c0d0"}, {"on_accent", "#2e3440"}, {"selection", "#434c5e"}, + {"success", "#a3be8c"}, {"warning", "#ebcb8b"}, {"error", "#bf616a"}, {"info", "#81a1c1"}, + {"bubble_in", "#3b4252"}, {"bubble_out", "#4c6a8a"}, {"bubble_fg", "#eceff4"}, + {"meta_in", "#7c8698"}, {"meta_out", "#88c0d0"}, {"tick_read", "#88c0d0"}, + {"service_bg", "#4c566a"}, {"service_fg", "#eceff4"}, {"unread", "#88c0d0"}, {"unread_muted", "#4c566a"}, + {"panel", "#292e39"}, {"accent2", "#8fbcbb"}, {"accent3", "#b48ead"}, {"code_bg", "#272c36"}, {"tool_bg", "#333a48"}}); + T("gruvbox", true, { + {"bg", "#282828"}, {"surface", "#3c3836"}, {"fg", "#ebdbb2"}, {"muted", "#928374"}, + {"border", "#504945"}, {"accent", "#83a598"}, {"on_accent", "#282828"}, {"selection", "#458588"}, + {"success", "#b8bb26"}, {"warning", "#fabd2f"}, {"error", "#fb4934"}, {"info", "#8ec07c"}, + {"bubble_in", "#3c3836"}, {"bubble_out", "#458588"}, {"bubble_fg", "#ebdbb2"}, + {"meta_in", "#928374"}, {"meta_out", "#83a598"}, {"tick_read", "#83a598"}, + {"service_bg", "#504945"}, {"service_fg", "#ebdbb2"}, {"unread", "#458588"}, {"unread_muted", "#665c54"}, + {"panel", "#1d2021"}, {"accent2", "#8ec07c"}, {"accent3", "#d3869b"}, {"code_bg", "#1d2021"}, {"tool_bg", "#32302f"}}); + T("ansi", true, { + {"bg", "black"}, {"surface", "black"}, {"fg", "white"}, {"muted", "bright_black"}, + {"border", "white"}, {"accent", "cyan"}, {"on_accent", "black"}, {"selection", "blue"}, + {"success", "green"}, {"warning", "yellow"}, {"error", "red"}, {"info", "cyan"}, + {"bubble_in", "black"}, {"bubble_out", "blue"}, {"bubble_fg", "white"}, + {"meta_in", "white"}, {"meta_out", "cyan"}, {"tick_read", "bright_blue"}, + {"service_bg", "black"}, {"service_fg", "white"}, {"unread", "blue"}, {"unread_muted", "white"}, + {"panel", "black"}, {"accent2", "cyan"}, {"accent3", "magenta"}, {"code_bg", "black"}, {"tool_bg", "black"}}); + active() = find(default_name()); +} + +std::shared_ptr TuiThemes::find(const std::string& name) { + for (auto& p : registry()) if (p.first == name) return p.second; + return nullptr; +} +std::shared_ptr TuiThemes::current() { init(); return active(); } +std::vector TuiThemes::names() { + init(); + std::vector o; + for (auto& p : registry()) o.push_back(p.first); + return o; +} +std::vector TuiThemes::sorted_names() { auto n = names(); std::sort(n.begin(), n.end()); return n; } +std::shared_ptr TuiThemes::set(const std::string& name) { + init(); + auto t = find(name); + if (!t) throw std::invalid_argument("unknown theme '" + name + "' (available: " + join_sorted(names()) + ")"); + active() = t; + return t; +} +std::string TuiThemes::resolve_name(const std::string& flag) { + init(); + std::string name = flag; + if (name.empty()) { const char* env = std::getenv("TAI_THEME"); if (env && *env) name = env; } + if (name.empty()) name = default_name(); + if (!find(name)) throw std::invalid_argument("unknown theme '" + name + "' (available: " + join_sorted(names()) + ")"); + return name; +} +std::string TuiThemes::cycle_name(const std::string& cur) { + auto n = names(); + for (size_t i = 0; i < n.size(); i++) if (n[i] == cur) return n[(i + 1) % n.size()]; + return default_name(); +} + +} // namespace detz::tui diff --git a/taicpp/src/tui/TuiTheme.h b/taicpp/src/tui/TuiTheme.h new file mode 100644 index 0000000..8f9c6e5 --- /dev/null +++ b/taicpp/src/tui/TuiTheme.h @@ -0,0 +1,38 @@ +#pragma once +// TuiTheme (named palette) and TuiThemes (the registry plus the active theme: +// tai.py TUI_THEMES, tui_register_theme, tui_theme, tui_set_theme, +// tui_resolve_theme_name, tui_cycle_theme_name). +#include "TuiStyle.h" +#include +#include +#include +#include +#include +namespace detz::tui { + +class TuiTheme { +public: + TuiTheme(std::string name, bool dark, std::vector> roles); + TuiColor color(const std::string& role) const; // None when absent + std::string name; + bool dark; + std::map roles; +}; + +class TuiThemes { +public: + static const std::vector& role_names(); // TUI_THEME_ROLES + static const char* default_name(); // TUI_THEME_DEFAULT + static std::shared_ptr register_theme(std::shared_ptr theme); // ValueError when roles are missing + static std::shared_ptr current(); // tui_theme() + static std::shared_ptr set(const std::string& name); // tui_set_theme, ValueError when unknown + static std::string resolve_name(const std::string& flag); // flag, TAI_THEME env, default + static std::string cycle_name(const std::string& current); + static std::vector names(); // insertion order + static std::vector sorted_names(); + static std::shared_ptr find(const std::string& name); +private: + static void init(); +}; + +} // namespace detz::tui diff --git a/taicpp/src/tui/TuiValue.cpp b/taicpp/src/tui/TuiValue.cpp new file mode 100644 index 0000000..57dc810 --- /dev/null +++ b/taicpp/src/tui/TuiValue.cpp @@ -0,0 +1,365 @@ +#include "TuiValue.h" +#include "TuiStr.h" +#include +#include +#include +#include +namespace detz::tui { + +const TuiValue& TuiValue::null_value() { static const TuiValue n; return n; } + +bool TuiValue::truthy() const { + switch (kind_) { + case Null: return false; + case Bool: case Int: return i_ != 0; + case Float: return f_ != 0.0; + case Str: return !s_.empty(); + case List: return !l_.empty(); + case Dict: return !d_.empty(); + case Tag: return true; + } + return false; +} +long long TuiValue::as_int(long long dflt) const { + if (kind_ == Int || kind_ == Bool) return i_; + if (kind_ == Float) return static_cast(f_); + return dflt; +} +double TuiValue::as_float(double dflt) const { + if (kind_ == Int || kind_ == Bool) return static_cast(i_); + if (kind_ == Float) return f_; + return dflt; +} + +static std::string float_repr(double v) { + if (std::isnan(v)) return "nan"; + if (std::isinf(v)) return v < 0 ? "-inf" : "inf"; + char buf[64]; + auto res = std::to_chars(buf, buf + sizeof buf, v, std::chars_format::scientific); + std::string sci(buf, res.ptr); // d.ddde+XX shortest + size_t epos = sci.find('e'); + std::string mant = sci.substr(0, epos); + int exp = std::atoi(sci.c_str() + epos + 1); + bool neg = !mant.empty() && mant[0] == '-'; + if (neg) mant = mant.substr(1); + std::string digits; + for (char c : mant) if (c != '.') digits.push_back(c); + std::string out; + if (exp < -4 || exp >= 16) { + out = digits.substr(0, 1); + if (digits.size() > 1) out += "." + digits.substr(1); + char eb[16]; + std::snprintf(eb, sizeof eb, "e%c%02d", exp < 0 ? '-' : '+', std::abs(exp)); + out += eb; + } else if (exp >= 0) { + if (static_cast(digits.size()) <= exp + 1) { + out = digits + std::string(static_cast(exp + 1) - digits.size(), '0') + ".0"; + } else { + out = digits.substr(0, static_cast(exp + 1)) + "." + digits.substr(static_cast(exp + 1)); + } + } else { + out = "0." + std::string(static_cast(-exp - 1), '0') + digits; + } + return neg ? "-" + out : out; +} + +std::string TuiValue::str() const { + switch (kind_) { + case Null: return "None"; + case Bool: return i_ ? "True" : "False"; + case Int: return std::to_string(i_); + case Float: return float_repr(f_); + case Str: return s_; + default: return repr(); + } +} + +static std::string py_str_repr(const std::string& s) { + bool has_single = s.find('\'') != std::string::npos; + bool has_double = s.find('"') != std::string::npos; + char q = (has_single && !has_double) ? '"' : '\''; + std::string out(1, q); + UStr u = TuiStr::u(s); + for (char32_t c : u) { + if (c == static_cast(q) || c == '\\') { out.push_back('\\'); out.push_back(static_cast(c)); } + else if (c == '\n') out += "\\n"; + else if (c == '\r') out += "\\r"; + else if (c == '\t') out += "\\t"; + else if (c < 0x20 || c == 0x7F) { char b[8]; std::snprintf(b, sizeof b, "\\x%02x", static_cast(c)); out += b; } + else out += TuiStr::s(c); + } + out.push_back(q); + return out; +} + +std::string TuiValue::repr() const { + switch (kind_) { + case Str: return py_str_repr(s_); + case List: { + std::string o = "["; + for (size_t i = 0; i < l_.size(); i++) { if (i) o += ", "; o += l_[i].repr(); } + return o + "]"; + } + case Dict: { + std::string o = "{"; + for (size_t i = 0; i < d_.size(); i++) { if (i) o += ", "; o += py_str_repr(d_[i].first) + ": " + d_[i].second.repr(); } + return o + "}"; + } + case Tag: return ""; + default: return str(); + } +} + +const TuiValue& TuiValue::at(size_t i) const { return i < l_.size() ? l_[i] : null_value(); } +bool TuiValue::has(const std::string& key) const { + for (auto& p : d_) if (p.first == key) return true; + return false; +} +const TuiValue& TuiValue::get(const std::string& key) const { + for (auto& p : d_) if (p.first == key) return p.second; + return null_value(); +} +TuiValue TuiValue::get(const std::string& key, const TuiValue& dflt) const { + for (auto& p : d_) if (p.first == key) return p.second; + return dflt; +} +TuiValue& TuiValue::set(const std::string& key, const TuiValue& v) { + if (kind_ != Dict) { kind_ = Dict; d_.clear(); } + for (auto& p : d_) if (p.first == key) { p.second = v; return p.second; } + d_.emplace_back(key, v); + return d_.back().second; +} +TuiValue& TuiValue::operator[](const std::string& key) { + if (kind_ != Dict) { kind_ = Dict; d_.clear(); } + for (auto& p : d_) if (p.first == key) return p.second; + d_.emplace_back(key, TuiValue()); + return d_.back().second; +} +bool TuiValue::erase(const std::string& key) { + for (size_t i = 0; i < d_.size(); i++) if (d_[i].first == key) { d_.erase(d_.begin() + static_cast(i)); return true; } + return false; +} + +bool TuiValue::operator==(const TuiValue& o) const { + bool num_a = kind_ == Int || kind_ == Float || kind_ == Bool; + bool num_b = o.kind_ == Int || o.kind_ == Float || o.kind_ == Bool; + if (num_a && num_b) { + if (kind_ != Float && o.kind_ != Float) return i_ == o.i_; + return as_float() == o.as_float(); + } + if (kind_ != o.kind_) return false; + switch (kind_) { + case Null: return true; + case Str: return s_ == o.s_; + case List: return l_ == o.l_; + case Dict: { + if (d_.size() != o.d_.size()) return false; + for (auto& p : d_) { if (!o.has(p.first) || !(o.get(p.first) == p.second)) return false; } + return true; + } + case Tag: return i_ == o.i_; + default: return false; + } +} + +static void json_str(std::string& out, const std::string& s, bool ensure_ascii) { + out.push_back('"'); + UStr u = TuiStr::u(s); + for (char32_t c : u) { + switch (c) { + case '"': out += "\\\""; break; + case '\\': out += "\\\\"; break; + case '\n': out += "\\n"; break; + case '\r': out += "\\r"; break; + case '\t': out += "\\t"; break; + case '\b': out += "\\b"; break; + case '\f': out += "\\f"; break; + default: + if (c < 0x20) { char b[8]; std::snprintf(b, sizeof b, "\\u%04x", static_cast(c)); out += b; } + else if (c > 0x7E && ensure_ascii) { + char b[16]; + if (c >= 0x10000) { + char32_t v = c - 0x10000; + std::snprintf(b, sizeof b, "\\u%04x\\u%04x", static_cast(0xD800 + (v >> 10)), static_cast(0xDC00 + (v & 0x3FF))); + } else std::snprintf(b, sizeof b, "\\u%04x", static_cast(c)); + out += b; + } else out += TuiStr::s(c); + } + } + out.push_back('"'); +} + +void TuiValue::dump_to(std::string& out, bool ea, bool compact) const { + const char* item_sep = compact ? "," : ", "; + const char* key_sep = compact ? ":" : ": "; + switch (kind_) { + case Null: out += "null"; break; + case Bool: out += i_ ? "true" : "false"; break; + case Int: out += std::to_string(i_); break; + case Float: out += std::isnan(f_) ? "NaN" : std::isinf(f_) ? (f_ < 0 ? "-Infinity" : "Infinity") : float_repr(f_); break; + case Str: json_str(out, s_, ea); break; + case List: + out.push_back('['); + for (size_t i = 0; i < l_.size(); i++) { if (i) out += item_sep; l_[i].dump_to(out, ea, compact); } + out.push_back(']'); + break; + case Dict: + out.push_back('{'); + for (size_t i = 0; i < d_.size(); i++) { if (i) out += item_sep; json_str(out, d_[i].first, ea); out += key_sep; d_[i].second.dump_to(out, ea, compact); } + out.push_back('}'); + break; + case Tag: out += "null"; break; + } +} +std::string TuiValue::dumps(bool ea, bool compact) const { std::string o; dump_to(o, ea, compact); return o; } + +namespace { +class JsonReader { +public: + JsonReader(const std::string& t) : t_(t) {} + bool parse(TuiValue& out) { + skip(); + if (!value(out, 0)) return false; + skip(); + return pos_ == t_.size(); + } +private: + const std::string& t_; + size_t pos_{0}; + void skip() { while (pos_ < t_.size() && (t_[pos_] == ' ' || t_[pos_] == '\t' || t_[pos_] == '\n' || t_[pos_] == '\r')) pos_++; } + bool lit(const char* w) { size_t n = std::char_traits::length(w); if (t_.compare(pos_, n, w) == 0) { pos_ += n; return true; } return false; } + bool value(TuiValue& out, int depth) { + if (depth > 200 || pos_ >= t_.size()) return false; + char c = t_[pos_]; + if (c == '{') { + pos_++; out = TuiValue::dict(); skip(); + if (pos_ < t_.size() && t_[pos_] == '}') { pos_++; return true; } + while (true) { + skip(); + std::string key; + if (!string(key)) return false; + skip(); + if (pos_ >= t_.size() || t_[pos_] != ':') return false; + pos_++; skip(); + TuiValue v; + if (!value(v, depth + 1)) return false; + out.set(key, v); + skip(); + if (pos_ >= t_.size()) return false; + if (t_[pos_] == ',') { pos_++; continue; } + if (t_[pos_] == '}') { pos_++; return true; } + return false; + } + } + if (c == '[') { + pos_++; out = TuiValue::list(); skip(); + if (pos_ < t_.size() && t_[pos_] == ']') { pos_++; return true; } + while (true) { + skip(); + TuiValue v; + if (!value(v, depth + 1)) return false; + out.push(v); + skip(); + if (pos_ >= t_.size()) return false; + if (t_[pos_] == ',') { pos_++; continue; } + if (t_[pos_] == ']') { pos_++; return true; } + return false; + } + } + if (c == '"') { std::string s; if (!string(s)) return false; out = TuiValue(s); return true; } + if (lit("true")) { out = TuiValue(true); return true; } + if (lit("false")) { out = TuiValue(false); return true; } + if (lit("null")) { out = TuiValue(); return true; } + if (lit("NaN")) { out = TuiValue(std::nan("")); return true; } + if (lit("-Infinity")) { out = TuiValue(-HUGE_VAL); return true; } + if (lit("Infinity")) { out = TuiValue(HUGE_VAL); return true; } + return number(out); + } + bool number(TuiValue& out) { + size_t start = pos_; + if (pos_ < t_.size() && t_[pos_] == '-') pos_++; + if (pos_ >= t_.size() || !(t_[pos_] >= '0' && t_[pos_] <= '9')) return false; + if (t_[pos_] == '0') pos_++; else while (pos_ < t_.size() && t_[pos_] >= '0' && t_[pos_] <= '9') pos_++; + bool flt = false; + if (pos_ < t_.size() && t_[pos_] == '.') { + flt = true; pos_++; + if (pos_ >= t_.size() || !(t_[pos_] >= '0' && t_[pos_] <= '9')) return false; + while (pos_ < t_.size() && t_[pos_] >= '0' && t_[pos_] <= '9') pos_++; + } + if (pos_ < t_.size() && (t_[pos_] == 'e' || t_[pos_] == 'E')) { + flt = true; pos_++; + if (pos_ < t_.size() && (t_[pos_] == '+' || t_[pos_] == '-')) pos_++; + if (pos_ >= t_.size() || !(t_[pos_] >= '0' && t_[pos_] <= '9')) return false; + while (pos_ < t_.size() && t_[pos_] >= '0' && t_[pos_] <= '9') pos_++; + } + std::string tok = t_.substr(start, pos_ - start); + if (flt) out = TuiValue(std::strtod(tok.c_str(), nullptr)); + else out = TuiValue(static_cast(std::strtoll(tok.c_str(), nullptr, 10))); + return true; + } + bool hex4(unsigned& v) { + if (pos_ + 4 > t_.size()) return false; + v = 0; + for (int i = 0; i < 4; i++) { + char c = t_[pos_ + static_cast(i)]; + v <<= 4; + if (c >= '0' && c <= '9') v |= static_cast(c - '0'); + else if (c >= 'a' && c <= 'f') v |= static_cast(c - 'a' + 10); + else if (c >= 'A' && c <= 'F') v |= static_cast(c - 'A' + 10); + else return false; + } + pos_ += 4; + return true; + } + bool string(std::string& out) { + if (pos_ >= t_.size() || t_[pos_] != '"') return false; + pos_++; + while (pos_ < t_.size()) { + char c = t_[pos_++]; + if (c == '"') return true; + if (c == '\\') { + if (pos_ >= t_.size()) return false; + char e = t_[pos_++]; + switch (e) { + case '"': out.push_back('"'); break; + case '\\': out.push_back('\\'); break; + case '/': out.push_back('/'); break; + case 'b': out.push_back('\b'); break; + case 'f': out.push_back('\f'); break; + case 'n': out.push_back('\n'); break; + case 'r': out.push_back('\r'); break; + case 't': out.push_back('\t'); break; + case 'u': { + unsigned v; + if (!hex4(v)) return false; + char32_t cp = v; + if (v >= 0xD800 && v <= 0xDBFF && pos_ + 1 < t_.size() && t_[pos_] == '\\' && t_[pos_ + 1] == 'u') { + size_t save = pos_; + pos_ += 2; + unsigned lo; + if (hex4(lo) && lo >= 0xDC00 && lo <= 0xDFFF) cp = 0x10000 + ((v - 0xD800) << 10) + (lo - 0xDC00); + else pos_ = save; + } + out += TuiStr::s(cp); + break; + } + default: return false; + } + } else if (static_cast(c) < 0x20) { + return false; + } else out.push_back(c); + } + return false; + } +}; +} // namespace + +TuiValue TuiValue::loads(const std::string& text, bool* ok) { + TuiValue v; + JsonReader r(text); + bool good = r.parse(v); + if (ok) *ok = good; + return good ? v : TuiValue(); +} + +} // namespace detz::tui diff --git a/taicpp/src/tui/TuiValue.h b/taicpp/src/tui/TuiValue.h new file mode 100644 index 0000000..14771af --- /dev/null +++ b/taicpp/src/tui/TuiValue.h @@ -0,0 +1,86 @@ +#pragma once +// TuiValue: the one dynamic value of the tui layer (option values, event +// extras, grr JSON). It stands in for "any Python object" where tai.py passes +// strs, ints, lists, dicts or sentinels around. Dict keeps insertion order like +// a Python dict. A Tag is a unique sentinel (TUI_CHAT_TYPE_ANSWER). +#include +#include +#include +#include +namespace detz::tui { + +class TuiValue { +public: + enum Kind { Null, Bool, Int, Float, Str, List, Dict, Tag }; + using Pair = std::pair; + + TuiValue() = default; + TuiValue(std::nullptr_t) {} + TuiValue(bool v) : kind_(Bool), i_(v ? 1 : 0) {} + TuiValue(int v) : kind_(Int), i_(v) {} + TuiValue(long v) : kind_(Int), i_(v) {} + TuiValue(long long v) : kind_(Int), i_(v) {} + TuiValue(unsigned long v) : kind_(Int), i_(static_cast(v)) {} + TuiValue(double v) : kind_(Float), f_(v) {} + TuiValue(const char* v) : kind_(Str), s_(v) {} + TuiValue(const std::string& v) : kind_(Str), s_(v) {} + TuiValue(std::string&& v) : kind_(Str), s_(std::move(v)) {} + + static TuiValue list() { TuiValue v; v.kind_ = List; return v; } + static TuiValue dict() { TuiValue v; v.kind_ = Dict; return v; } + static TuiValue tag() { static long counter = 0; TuiValue v; v.kind_ = Tag; v.i_ = ++counter; return v; } + + Kind kind() const { return kind_; } + bool is_null() const { return kind_ == Null; } + bool is_bool() const { return kind_ == Bool; } + bool is_int() const { return kind_ == Int; } + bool is_num() const { return kind_ == Int || kind_ == Float || kind_ == Bool; } + bool is_str() const { return kind_ == Str; } + bool is_list() const { return kind_ == List; } + bool is_dict() const { return kind_ == Dict; } + + // python truthiness + bool truthy() const; + long long as_int(long long dflt = 0) const; + double as_float(double dflt = 0.0) const; + const std::string& as_str() const { return s_; } // only meaningful for Str + std::string str() const; // python str(value) + std::string repr() const; // python repr(value) + + // list api + size_t size() const { return kind_ == List ? l_.size() : kind_ == Dict ? d_.size() : kind_ == Str ? s_.size() : 0; } + void push(const TuiValue& v) { if (kind_ != List) { kind_ = List; l_.clear(); } l_.push_back(v); } + const TuiValue& at(size_t i) const; + TuiValue& at(size_t i) { return l_.at(i); } + const std::vector& items() const { return l_; } + std::vector& items() { return l_; } + + // dict api + bool has(const std::string& key) const; + const TuiValue& get(const std::string& key) const; // Null value when missing + TuiValue get(const std::string& key, const TuiValue& dflt) const; + TuiValue& set(const std::string& key, const TuiValue& v); + TuiValue& operator[](const std::string& key); // inserts Null when missing + bool erase(const std::string& key); + const std::vector& pairs() const { return d_; } + std::vector& pairs() { return d_; } + + bool operator==(const TuiValue& o) const; + bool operator!=(const TuiValue& o) const { return !(*this == o); } + + // JSON (python json module compatible for the cases tai.py uses) + std::string dumps(bool ensure_ascii = true, bool compact = false) const; + static TuiValue loads(const std::string& text, bool* ok = nullptr); + +private: + Kind kind_{Null}; + long long i_{0}; + double f_{0.0}; + std::string s_; + std::vector l_; + std::vector d_; + static const TuiValue& null_value(); + void dump_to(std::string& out, bool ensure_ascii, bool compact) const; +}; + +} // namespace detz::tui diff --git a/taicpp/src/tui/TuiVtScreen.cpp b/taicpp/src/tui/TuiVtScreen.cpp new file mode 100644 index 0000000..d4d87ce --- /dev/null +++ b/taicpp/src/tui/TuiVtScreen.cpp @@ -0,0 +1,625 @@ +#include "TuiVtScreen.h" +#include +#include +#include +namespace detz::tui { + +namespace { +// python's graphics (DEC special) map for ESC ( 0 +char32_t graphics_char(char32_t c) { + switch (c) { + case '`': return 0x25c6; case 'a': return 0x2592; case 'f': return 0x00b0; case 'g': return 0x00b1; + case 'j': return 0x2518; case 'k': return 0x2510; case 'l': return 0x250c; case 'm': return 0x2514; + case 'n': return 0x253c; case 'o': return 0x23ba; case 'p': return 0x23bb; case 'q': return 0x2500; + case 'r': return 0x23bc; case 's': return 0x23bd; case 't': return 0x251c; case 'u': return 0x2524; + case 'v': return 0x2534; case 'w': return 0x252c; case 'x': return 0x2502; case 'y': return 0x2264; + case 'z': return 0x2265; case '{': return 0x03c0; case '|': return 0x2260; case '}': return 0x00a3; + case '~': return 0x00b7; + default: return c; + } +} +template V vslice(const V& v, long a, long b) { + long n = static_cast(v.size()); + if (a < 0) a += n; + if (b < 0) b += n; + a = std::max(0L, std::min(a, n)); + b = std::max(0L, std::min(b, n)); + if (a >= b) return V(); + return V(v.begin() + a, v.begin() + b); +} +template V vcat(V a, const V& b) { a.insert(a.end(), b.begin(), b.end()); return a; } +bool is_digit(char32_t c) { return c >= '0' && c <= '9'; } +} // namespace + +TuiVtScreen::TuiVtScreen(long r, long c) : rows(std::max(1L, r)), cols(std::max(1L, c)), region_bottom(0), margin_right(0) { + grid.assign(static_cast(rows), TuiVtRow(static_cast(cols))); + region_bottom = rows - 1; + margin_right = cols - 1; +} + +std::string TuiVtScreen::take_replies() { std::string out = replies; replies.clear(); return out; } + +TuiVtCell TuiVtScreen::blank() const { + TuiVtCell c; + c.set = true; c.ch = U' '; c.fg = std::nullopt; c.bg = bg; c.attr = 0; + return c; +} +TuiVtRow TuiVtScreen::blank_row() const { return TuiVtRow(static_cast(cols), blank()); } + +std::vector TuiVtScreen::text_lines() const { + std::vector lines; + for (auto& row : grid) { + long end = static_cast(row.size()); + while (end > 0 && !row[static_cast(end - 1)].set) end--; + UStr text; + for (long i = 0; i < end; i++) text.push_back(row[static_cast(i)].set ? row[static_cast(i)].ch : U' '); + lines.push_back(TuiStr::s(end < static_cast(row.size()) ? text : TuiStr::rstrip(text))); + } + return lines; +} + +// Incremental UTF-8 decoder with errors="replace" (python codecs incremental decoder). +UStr TuiVtScreen::decode(const std::string& data) { + std::string in = pending_bytes_ + data; + pending_bytes_.clear(); + UStr out; + size_t i = 0, n = in.size(); + while (i < n) { + unsigned char c = static_cast(in[i]); + if (c < 0x80) { out.push_back(c); i++; continue; } + size_t need = 0; + unsigned char lo = 0x80, hi = 0xBF; + char32_t cp = 0; + if (c >= 0xC2 && c <= 0xDF) { need = 2; cp = c & 0x1F; } + else if (c >= 0xE0 && c <= 0xEF) { need = 3; cp = c & 0x0F; if (c == 0xE0) lo = 0xA0; if (c == 0xED) hi = 0x9F; } + else if (c >= 0xF0 && c <= 0xF4) { need = 4; cp = c & 0x07; if (c == 0xF0) lo = 0x90; if (c == 0xF4) hi = 0x8F; } + else { out.push_back(0xFFFD); i++; continue; } + size_t k = 1; + bool bad = false; + while (k < need) { + if (i + k >= n) break; // truncated, maybe more bytes later + unsigned char cc = static_cast(in[i + k]); + unsigned char l = k == 1 ? lo : 0x80, h = k == 1 ? hi : 0xBF; + if (cc < l || cc > h) { bad = true; break; } + cp = (cp << 6) | (cc & 0x3F); + k++; + } + if (bad) { out.push_back(0xFFFD); i += k; continue; } + if (k < need) { pending_bytes_ = in.substr(i); break; } + out.push_back(cp); + i += need; + } + return out; +} + +void TuiVtScreen::feed(const std::string& data) { + if (data.empty()) return; + buf_ += decode(data); + if (buf_.size() > TAIL_GUARD) buf_ = buf_.substr(buf_.size() - TAIL_GUARD); + UStr buf = buf_; + long pos = 0, end = static_cast(buf.size()); + while (pos < end) { + char32_t ch = buf[static_cast(pos)]; + if (ch == 0x1B) { + long nxt = parse_esc(buf, pos, end); + if (nxt < 0) break; + pos = nxt; + } else if (ch == 0x9B) { + long nxt = parse_csi(buf, pos + 1, end); + if (nxt < 0) break; + pos = nxt; + } else if (ch == 0x90 || ch == 0x9D || ch == 0x9E || ch == 0x9F) { + long nxt = parse_until_st(buf, pos + 1, end); + if (nxt < 0) break; + pos = nxt; + } else { + ground(ch); + pos++; + } + } + buf_ = buf.substr(static_cast(pos)); +} + +void TuiVtScreen::ground(char32_t ch) { + if (ch == '\n' || ch == 0x0B || ch == 0x0C) { + wrap_ = false; + line_feed(); + } else if (ch == '\r') { + wrap_ = false; + cx = 0; + } else if (ch == '\b') { + wrap_ = false; + cx = std::max(0L, cx - 1); + } else if (ch == '\t') { + wrap_ = false; + cx = std::min(cols - 1, (cx / TAB_WIDTH + 1) * TAB_WIDTH); + } else if (ch == 0x07 || ch == 0x00 || ch == 0x0E || ch == 0x0F) { + } else if (ch < 0x20 || ch == 0x7F) { + } else { + if (wrap_) { + wrap_ = false; + cx = 0; + line_feed(); + } + if (graphics_) ch = graphics_char(ch); + last_char_ = UStr(1, ch); + TuiVtCell cell; + cell.set = true; cell.ch = ch; cell.fg = fg; cell.bg = bg; cell.attr = attr; + grid[static_cast(cy)][static_cast(cx)] = cell; + if (cx == cols - 1) wrap_ = true; + else cx++; + } +} + +long TuiVtScreen::parse_esc(const UStr& buf, long pos, long end) { + if (pos + 1 >= end) return -1; + char32_t nxt = buf[static_cast(pos + 1)]; + if (nxt == '[') return parse_csi(buf, pos + 2, end); + if (nxt == ']') { + long stop = parse_until_st(buf, pos + 2, end); + if (stop >= 0) osc(buf.substr(static_cast(pos + 2), static_cast(stop - (pos + 2)))); + return stop; + } + if (nxt == 'P' || nxt == 'X' || nxt == '^' || nxt == '_') return parse_until_st(buf, pos + 2, end); + if (nxt == '(' || nxt == ')' || nxt == '#' || nxt == '%') { + if (pos + 2 >= end) return -1; + if (nxt == '(') graphics_ = buf[static_cast(pos + 2)] == '0'; + return pos + 3; + } + if (nxt == '7') { + saved_ = SavedCursor{cx, cy, fg, bg, attr}; + } else if (nxt == '8') { + if (saved_) { + cx = saved_->cx; cy = saved_->cy; fg = saved_->fg; bg = saved_->bg; attr = saved_->attr; + clamp_cursor(); + } + } else if (nxt == 'M') { + wrap_ = false; + if (cy == region_top) { + if (inside_margins()) scroll_down(region_top, region_bottom); + } else { + cy = std::max(0L, cy - 1); + } + } else if (nxt == 'c') { + reset(); + } else if (nxt == 'D') { + line_feed(); + } else if (nxt == 'E') { + cx = 0; + line_feed(); + } + return pos + 2; +} + +void TuiVtScreen::osc(const UStr& body_in) { + UStr body = body_in; + while (!body.empty() && (body.back() == 0x07 || body.back() == 0x1B || body.back() == '\\')) body.pop_back(); + size_t semi = body.find(U';'); + UStr code = semi == UStr::npos ? body : body.substr(0, semi); + UStr text = semi == UStr::npos ? UStr() : body.substr(semi + 1); + if (code == U"0" || code == U"2") title = TuiStr::s(TuiStr::head(text, 256)); +} + +long TuiVtScreen::parse_csi(const UStr& buf, long pos, long end) { + long cur = pos; + static const UStr marks = U";:?>(cur)]) || marks.find(buf[static_cast(cur)]) != UStr::npos)) { + cur++; + if (cur - pos > 256) return pos + 2; + } + if (cur >= end) return -1; + char32_t final_char = buf[static_cast(cur)]; + if (!(U'@' <= final_char && final_char <= U'~')) return pos + 2; + dispatch_csi(buf.substr(static_cast(pos), static_cast(cur - pos)), final_char); + return cur + 1; +} + +long TuiVtScreen::parse_until_st(const UStr& buf, long pos, long end) { + long cur = pos; + while (cur < end) { + char32_t c = buf[static_cast(cur)]; + if (c == 0x07) return cur + 1; + if (c == 0x1B) { + if (cur + 1 >= end) return -1; + if (buf[static_cast(cur + 1)] == '\\') return cur + 2; + cur += 2; + continue; + } + cur++; + if (cur - pos > 4096) return cur; + } + return -1; +} + +std::vector TuiVtScreen::params(const UStr& body_in, long count, long dflt) const { + UStr body = body_in; + if (!body.empty() && (body[0] == '?' || body[0] == '>' || body[0] == '=')) body = body.substr(1); + else if (!body.empty() && (body[0] == '!' || body[0] == '"' || body[0] == '$')) body.clear(); + for (auto& c : body) if (c == ':') c = ';'; + auto parts = TuiStr::split(body, U';'); + if (parts.size() > 64) parts.resize(64); + std::vector values; + for (auto& raw : parts) { + UStr part = TuiStr::strip(raw); + if (part.empty()) { values.push_back(dflt); continue; } + size_t i = 0; + bool neg = false; + if (part[0] == '+' || part[0] == '-') { neg = part[0] == '-'; i = 1; } + bool ok = i < part.size(); + long v = 0; + for (size_t k = i; k < part.size() && ok; k++) { + if (!is_digit(part[k])) ok = false; + else if (v < 1000000000000L) v = v * 10 + (part[k] - '0'); + } + values.push_back(ok ? (neg ? -v : v) : dflt); + } + if (count >= 0) while (static_cast(values.size()) < count) values.push_back(dflt); + return values; +} + +void TuiVtScreen::dispatch_csi(const UStr& body, char32_t final_char) { + bool priv = !body.empty() && (body[0] == '?' || body[0] == '>'); + if (final_char == 'm') { sgr(body); return; } + if (final_char == 'c' && (body.empty() || body == U"0" || body == U">" || body == U">0")) { + replies += (!body.empty() && body[0] == '>') ? "\x1b[>0;0;0c" : "\x1b[?1;2c"; + return; + } + if (final_char == 'p' && !body.empty() && body.back() == '$') { + long mode_n = params(body.substr(0, body.size() - 1), 1, 0)[0]; + int state = -1; // -1: unknown (None) + if (priv) { + if (mode_n == 25) state = cursor_visible; else if (mode_n == 1) state = app_cursor; + else if (mode_n == 2004) state = bracketed_paste; else if (mode_n == 69) state = lr_mode; + else if (mode_n == 1049) state = alt_ != nullptr; + } + char buf[64]; + std::snprintf(buf, sizeof buf, "\x1b[%s%ld;%d$y", priv ? "?" : "", mode_n, state < 0 ? 0 : state ? 1 : 2); + replies += buf; + return; + } + if (final_char == 'n' && !priv) { + long code = params(body, 1, 0)[0]; + if (code == 5) replies += "\x1b[0n"; + else if (code == 6) { char buf[48]; std::snprintf(buf, sizeof buf, "\x1b[%ld;%ldR", cy + 1, cx + 1); replies += buf; } + return; + } + if (final_char == 'h' || final_char == 'l') { mode(body, final_char == 'h'); return; } + if (priv) return; + switch (final_char) { + case 'A': { wrap_ = false; long floor_ = in_region() ? region_top : 0; cy = std::max(floor_, cy - params(body, 1)[0]); break; } + case 'B': { wrap_ = false; long ceil_ = in_region() ? region_bottom : rows - 1; cy = std::min(ceil_, cy + params(body, 1)[0]); break; } + case 'C': wrap_ = false; cx = std::min(cols - 1, cx + params(body, 1)[0]); break; + case 'D': wrap_ = false; cx = std::max(0L, cx - params(body, 1)[0]); break; + case 'E': wrap_ = false; cy = std::min(rows - 1, cy + params(body, 1)[0]); cx = 0; break; + case 'F': wrap_ = false; cy = std::max(0L, cy - params(body, 1)[0]); cx = 0; break; + case 'G': wrap_ = false; cx = std::min(cols - 1, std::max(0L, params(body, 1)[0] - 1)); break; + case 'H': case 'f': { + wrap_ = false; + auto rc = params(body, 2); + cy = std::min(rows - 1, std::max(0L, rc[0] - 1)); + cx = std::min(cols - 1, std::max(0L, rc[1] - 1)); + break; + } + case 'd': wrap_ = false; cy = std::min(rows - 1, std::max(0L, params(body, 1)[0] - 1)); break; + case 'J': wrap_ = false; erase_display(params(body, 1, 0)[0]); break; + case 'K': wrap_ = false; erase_line(params(body, 1, 0)[0]); break; + case 'L': wrap_ = false; insert_lines(params(body, 1)[0]); break; + case 'M': wrap_ = false; delete_lines(params(body, 1)[0]); break; + case 'P': wrap_ = false; delete_chars(params(body, 1)[0]); break; + case '@': wrap_ = false; insert_chars(params(body, 1)[0]); break; + case 'X': wrap_ = false; erase_chars(params(body, 1)[0]); break; + case 'S': { wrap_ = false; long n = std::min(params(body, 1)[0], rows); for (long i = 0; i < n; i++) scroll_up(region_top, region_bottom); break; } + case 'T': { wrap_ = false; long n = std::min(params(body, 1)[0], rows); for (long i = 0; i < n; i++) scroll_down(region_top, region_bottom); break; } + case 'r': { + auto tb = params(body, 2); + long top = std::min(rows, std::max(1L, tb[0])); + long bottom = std::min(rows, std::max(1L, tb[1])); + if (top < bottom) { region_top = top - 1; region_bottom = bottom - 1; cx = 0; cy = region_top; } + break; + } + case 'b': + if (!last_char_.empty()) { + long n = std::min(params(body, 1)[0], cols * rows); + UStr lc = last_char_; + for (long i = 0; i < n; i++) ground(lc[0]); + } + break; + case 's': + if (lr_mode) { + auto lr = params(body, 2); + long left = std::min(cols, std::max(1L, lr[0])); + long semis = static_cast(std::count(body.begin(), body.end(), U';')); + long right = std::min(cols, std::max(1L, semis ? lr[1] : cols)); + if (left < right) { margin_left = left - 1; margin_right = right - 1; cx = 0; cy = 0; } + } else { + saved_ = SavedCursor{cx, cy, fg, bg, attr}; + } + break; + case 'u': + if (saved_) { cx = saved_->cx; cy = saved_->cy; fg = saved_->fg; bg = saved_->bg; attr = saved_->attr; clamp_cursor(); } + break; + default: break; + } +} + +void TuiVtScreen::mode(const UStr& body, bool state) { + bool priv = !body.empty() && body[0] == '?'; + for (long value : params(body, -1, 0)) { + if (value == 25 && priv) cursor_visible = state; + else if (value == 1 && priv) app_cursor = state; + else if (value == 2004 && priv) bracketed_paste = state; + else if (value == 69 && priv) { lr_mode = state; margin_left = 0; margin_right = cols - 1; } + else if ((value == 47 || value == 1047 || value == 1049) && priv) alt_screen(state); + } +} + +void TuiVtScreen::alt_screen(bool state) { + if (state && !alt_) { + alt_ = std::make_unique(AltState{grid, cx, cy, fg, bg, attr, region_top, region_bottom, saved_, cursor_visible}); + grid.assign(static_cast(rows), TuiVtRow(static_cast(cols))); + cx = 0; cy = 0; fg = std::nullopt; bg = std::nullopt; attr = 0; + region_top = 0; region_bottom = rows - 1; + saved_ = std::nullopt; + wrap_ = false; + } else if (!state && alt_) { + grid = alt_->grid; cx = alt_->cx; cy = alt_->cy; fg = alt_->fg; bg = alt_->bg; attr = alt_->attr; + region_top = alt_->region_top; region_bottom = alt_->region_bottom; saved_ = alt_->saved; cursor_visible = alt_->cursor_visible; + alt_.reset(); + wrap_ = false; + } +} + +void TuiVtScreen::sgr(const UStr& body) { + UStr stripped = body; + while (!stripped.empty() && stripped.front() == '?') stripped.erase(stripped.begin()); + while (!stripped.empty() && stripped.back() == '?') stripped.pop_back(); + std::vector values = stripped.empty() ? std::vector{0} : params(body, -1, 0); + size_t pos = 0; + while (pos < values.size()) { + long code = values[pos]; + if (code == 0) { fg = std::nullopt; bg = std::nullopt; attr = 0; } + else if (code == 1) attr |= ATTR_BOLD; + else if (code == 2) attr |= ATTR_DIM; + else if (code == 3) attr |= ATTR_ITALIC; + else if (code == 4) attr |= ATTR_UNDERLINE; + else if (code == 5) attr |= ATTR_BLINK; + else if (code == 7) attr |= ATTR_REVERSE; + else if (code == 8) attr |= ATTR_HIDDEN; + else if (code == 9) attr |= ATTR_STRIKE; + else if (code == 22) attr &= ~(ATTR_BOLD | ATTR_DIM); + else if (code == 23) attr &= ~ATTR_ITALIC; + else if (code == 24) attr &= ~ATTR_UNDERLINE; + else if (code == 25) attr &= ~ATTR_BLINK; + else if (code == 27) attr &= ~ATTR_REVERSE; + else if (code == 28) attr &= ~ATTR_HIDDEN; + else if (code == 29) attr &= ~ATTR_STRIKE; + else if ((30 <= code && code <= 37) || (90 <= code && code <= 97)) fg = std::to_string(code); + else if (code == 39) fg = std::nullopt; + else if ((40 <= code && code <= 47) || (100 <= code && code <= 107)) bg = std::to_string(code); + else if (code == 49) bg = std::nullopt; + else if (code == 38 || code == 48) { + std::optional& target = code == 38 ? fg : bg; + char buf[64]; + if (pos + 2 < values.size() && values[pos + 1] == 5) { + std::snprintf(buf, sizeof buf, "%ld;5;%ld", code, values[pos + 2] & 0xFF); + target = buf; + pos += 2; + } else if (pos + 4 < values.size() && values[pos + 1] == 2) { + std::snprintf(buf, sizeof buf, "%ld;2;%ld;%ld;%ld", code, values[pos + 2] & 0xFF, values[pos + 3] & 0xFF, values[pos + 4] & 0xFF); + target = buf; + pos += 4; + } + } + pos++; + } +} + +void TuiVtScreen::line_feed() { + if (cy == region_bottom) { + if (inside_margins()) scroll_up(region_top, region_bottom); + } else { + cy = std::min(rows - 1, cy + 1); + } +} + +std::pair TuiVtScreen::margins() const { + if (lr_mode) return {margin_left, margin_right}; + return {0, cols - 1}; +} +bool TuiVtScreen::inside_margins() const { + auto lr = margins(); + return lr.first <= cx && cx <= lr.second; +} + +void TuiVtScreen::scroll_up(long top, long bottom, bool record) { + auto lr = margins(); + long left = lr.first, right = lr.second; + if (left > 0 || right < cols - 1) { + for (long row = top; row < bottom; row++) { + auto& g = grid[static_cast(row)]; + grid[static_cast(row)] = vcat(vcat(vslice(g, 0, left), vslice(grid[static_cast(row + 1)], left, right + 1)), vslice(g, right + 1, static_cast(g.size()))); + } + auto& g = grid[static_cast(bottom)]; + grid[static_cast(bottom)] = vcat(vcat(vslice(g, 0, left), TuiVtRow(static_cast(right - left + 1), blank())), vslice(g, right + 1, static_cast(g.size()))); + return; + } + if (record && history && top == 0 && bottom == rows - 1 && !alt_) { + history->push_back(grid[0]); + history_pushed++; + } + for (long row = top; row < bottom; row++) grid[static_cast(row)] = grid[static_cast(row + 1)]; + grid[static_cast(bottom)] = blank_row(); +} + +void TuiVtScreen::scroll_down(long top, long bottom) { + auto lr = margins(); + long left = lr.first, right = lr.second; + if (left > 0 || right < cols - 1) { + for (long row = bottom; row > top; row--) { + auto& g = grid[static_cast(row)]; + grid[static_cast(row)] = vcat(vcat(vslice(g, 0, left), vslice(grid[static_cast(row - 1)], left, right + 1)), vslice(g, right + 1, static_cast(g.size()))); + } + auto& g = grid[static_cast(top)]; + grid[static_cast(top)] = vcat(vcat(vslice(g, 0, left), TuiVtRow(static_cast(right - left + 1), blank())), vslice(g, right + 1, static_cast(g.size()))); + return; + } + for (long row = bottom; row > top; row--) grid[static_cast(row)] = grid[static_cast(row - 1)]; + grid[static_cast(top)] = blank_row(); +} + +void TuiVtScreen::erase_display(long m) { + if (m == 2 || m == 3) { + for (long row = 0; row < rows; row++) grid[static_cast(row)] = blank_row(); + cx = 0; cy = 0; + } else if (m == 1) { + for (long row = 0; row < cy; row++) grid[static_cast(row)] = blank_row(); + for (long col = 0; col <= cx; col++) grid[static_cast(cy)][static_cast(col)] = blank(); + } else { + for (long col = cx; col < cols; col++) grid[static_cast(cy)][static_cast(col)] = blank(); + for (long row = cy + 1; row < rows; row++) grid[static_cast(row)] = blank_row(); + } +} + +void TuiVtScreen::erase_line(long m) { + if (m == 1) { + for (long col = 0; col <= cx; col++) grid[static_cast(cy)][static_cast(col)] = blank(); + } else if (m == 2) { + grid[static_cast(cy)] = blank_row(); + } else { + for (long col = cx; col < cols; col++) grid[static_cast(cy)][static_cast(col)] = blank(); + } +} + +void TuiVtScreen::insert_lines(long count) { + count = std::min(std::max(1L, count), region_bottom - cy + 1); + if (cy < region_top || cy > region_bottom || !inside_margins()) return; + for (long i = 0; i < count; i++) scroll_down(cy, region_bottom); +} +void TuiVtScreen::delete_lines(long count) { + count = std::min(std::max(1L, count), region_bottom - cy + 1); + if (cy < region_top || cy > region_bottom || !inside_margins()) return; + for (long i = 0; i < count; i++) scroll_up(cy, region_bottom, false); +} +void TuiVtScreen::delete_chars(long count) { + TuiVtRow row = grid[static_cast(cy)]; + TuiVtRow rest = vslice(row, cx + count, static_cast(row.size())); + TuiVtRow out = vslice(row, 0, cx); + out = vcat(out, rest); + long pad = std::min(std::max(1L, count), cols - cx); + for (long i = 0; i < pad; i++) out.push_back(blank()); + grid[static_cast(cy)] = out; +} +void TuiVtScreen::insert_chars(long count) { + TuiVtRow row = grid[static_cast(cy)]; + long room = cols - cx; + count = std::min(std::max(1L, count), room); + TuiVtRow mid = TuiVtRow(static_cast(count), blank()); + mid = vcat(mid, vslice(row, cx, static_cast(row.size()))); + mid = vslice(mid, 0, room); + grid[static_cast(cy)] = vcat(vslice(row, 0, cx), mid); +} +void TuiVtScreen::erase_chars(long count) { + long stop = std::min(cols, cx + std::max(1L, count)); + for (long col = cx; col < stop; col++) grid[static_cast(cy)][static_cast(col)] = blank(); +} + +void TuiVtScreen::clamp_cursor() { + cx = std::min(cols - 1, std::max(0L, cx)); + cy = std::min(rows - 1, std::max(0L, cy)); +} + +void TuiVtScreen::reset() { + for (long row = 0; row < rows; row++) grid[static_cast(row)] = blank_row(); + cx = 0; cy = 0; fg = std::nullopt; bg = std::nullopt; attr = 0; + cursor_visible = true; + region_top = 0; region_bottom = rows - 1; + lr_mode = false; margin_left = 0; margin_right = cols - 1; + wrap_ = false; saved_ = std::nullopt; alt_.reset(); + app_cursor = false; bracketed_paste = false; graphics_ = false; +} + +void TuiVtScreen::resize(long r, long c) { + r = std::max(1L, r); + c = std::max(1L, c); + std::vector g; + for (long row = 0; row < r; row++) { + if (row < rows) { + TuiVtRow old = grid[static_cast(row)]; + if (c <= cols) g.push_back(vslice(old, 0, c)); + else { old.resize(old.size() + static_cast(c - cols)); g.push_back(old); } + } else { + g.push_back(TuiVtRow(static_cast(c))); + } + } + grid = g; + rows = r; cols = c; + region_top = 0; region_bottom = r - 1; + lr_mode = false; margin_left = 0; margin_right = c - 1; + clamp_cursor(); + wrap_ = false; +} + +std::string TuiVtScreen::text(long limit) const { + std::vector sel; + if (limit < 0 || limit == 0) sel = grid; // grid[-0:] is the whole grid + else sel = vslice(grid, -limit, static_cast(grid.size())); + std::string out; + for (size_t i = 0; i < sel.size(); i++) { + UStr line; + for (auto& cell : sel[i]) line.push_back(cell.set ? cell.ch : U' '); + if (i) out += "\n"; + out += TuiStr::s(TuiStr::rstrip(line)); + } + return out; +} + +TuiColor TuiVtScreen::frag_to_color(const std::optional& frag_opt, bool) { + if (!frag_opt) return TuiColor(); + const std::string& frag = *frag_opt; + static const char* names[] = {"black", "red", "green", "yellow", "blue", "magenta", "cyan", "white"}; + auto parse = [](const std::string& s, long& out) { + if (s.empty()) return false; + size_t i = 0; + bool neg = false; + if (s[0] == '+' || s[0] == '-') { neg = s[0] == '-'; i = 1; } + if (i >= s.size()) return false; + long v = 0; + for (; i < s.size(); i++) { if (s[i] < '0' || s[i] > '9') return false; v = v * 10 + (s[i] - '0'); } + out = neg ? -v : v; + return true; + }; + if (frag.find(';') == std::string::npos) { + long code; + if (!parse(frag, code)) return TuiColor(); + if (30 <= code && code <= 37) return TuiColor(names[code - 30]); + if (90 <= code && code <= 97) return TuiColor(std::string("bright_") + names[code - 90]); + if (40 <= code && code <= 47) return TuiColor(names[code - 40]); + if (100 <= code && code <= 107) return TuiColor(std::string("bright_") + names[code - 100]); + return TuiColor(); + } + std::vector nums; + size_t from = 0; + while (true) { + size_t at = frag.find(';', from); + std::string part = frag.substr(from, at == std::string::npos ? std::string::npos : at - from); + long v; + if (!parse(part, v)) return TuiColor(); + nums.push_back(v); + if (at == std::string::npos) break; + from = at + 1; + } + if (nums.size() == 3 && nums[1] == 5) return TuiColor(static_cast(nums[2])); + if (nums.size() == 5 && nums[1] == 2) return TuiColor::rgb(static_cast(nums[2]), static_cast(nums[3]), static_cast(nums[4])); + return TuiColor(); +} + +StyleP TuiVtScreen::style_for(const TuiVtCell& cell, const TuiStyle& base) { + if (!cell.set) return std::make_shared(base); + TuiColor f = frag_to_color(cell.fg, false); + TuiColor b = frag_to_color(cell.bg, true); + return std::make_shared(f.truthy() ? f : base.fg(), b.truthy() ? b : base.bg(), + (cell.attr & ATTR_BOLD) != 0, (cell.attr & ATTR_DIM) != 0, (cell.attr & ATTR_UNDERLINE) != 0, + (cell.attr & ATTR_REVERSE) != 0, (cell.attr & ATTR_STRIKE) != 0); +} + +} // namespace detz::tui diff --git a/taicpp/src/tui/TuiVtScreen.h b/taicpp/src/tui/TuiVtScreen.h new file mode 100644 index 0000000..a40c6e4 --- /dev/null +++ b/taicpp/src/tui/TuiVtScreen.h @@ -0,0 +1,107 @@ +#pragma once +// TuiVtScreen: in-memory VT100/ANSI screen, a grid plus a streaming byte parser +// (tai.py TuiVtScreen). A cell is unwritten or (char, fg, bg, attr) with +// SGR-fragment colors and an attribute bitmask. Unknown sequences are skipped, +// never fatal. Wide characters count as one column each (same known gap). +#include "TuiStr.h" +#include "TuiStyle.h" +#include +#include +#include +#include +namespace detz::tui { + +struct TuiVtCell { + bool set{false}; // false: never written (python None) + char32_t ch{U' '}; + std::optional fg, bg; // SGR fragment ("31", "38;5;12", "38;2;r;g;b") + int attr{0}; + bool operator==(const TuiVtCell& o) const { + if (set != o.set) return false; + if (!set) return true; + return ch == o.ch && fg == o.fg && bg == o.bg && attr == o.attr; + } + bool operator!=(const TuiVtCell& o) const { return !(*this == o); } +}; +using TuiVtRow = std::vector; + +class TuiVtScreen { +public: + static constexpr int ATTR_BOLD = 1, ATTR_DIM = 2, ATTR_UNDERLINE = 4, ATTR_REVERSE = 8, ATTR_STRIKE = 16, + ATTR_ITALIC = 32, ATTR_BLINK = 64, ATTR_HIDDEN = 128; + static constexpr int TAB_WIDTH = 8; + static constexpr size_t TAIL_GUARD = 65536; + + TuiVtScreen(long rows, long cols); + + // state (public like the python attributes) + long rows, cols; + std::vector grid; + long cx{0}, cy{0}; + std::optional fg, bg; + int attr{0}; + bool cursor_visible{true}; + long region_top{0}, region_bottom; + bool lr_mode{false}; + long margin_left{0}, margin_right; + std::shared_ptr> history; // python: None unless a caller sets a list + long history_pushed{0}; + bool app_cursor{false}; + bool bracketed_paste{false}; + std::string title; + std::string replies; + + bool alt_active() const { return alt_ != nullptr; } + std::string take_replies(); + TuiVtCell blank() const; + std::vector text_lines() const; + void feed(const std::string& data); + void line_feed(); + void scroll_up(long top, long bottom, bool record = true); + void scroll_down(long top, long bottom); + void reset(); + void resize(long rows, long cols); + std::string text(long limit = -1) const; // limit < 0: python limit=None + static StyleP style_for(const TuiVtCell& cell, const TuiStyle& base); + static TuiColor frag_to_color(const std::optional& frag, bool background); + +private: + struct SavedCursor { long cx, cy; std::optional fg, bg; int attr; }; + struct AltState { + std::vector grid; long cx, cy; std::optional fg, bg; int attr; + long region_top, region_bottom; std::optional saved; bool cursor_visible; + }; + bool wrap_{false}; + std::optional saved_; + std::unique_ptr alt_; + UStr buf_; + std::string pending_bytes_; // incomplete utf-8 tail of the incremental decoder + bool graphics_{false}; + UStr last_char_; + + UStr decode(const std::string& data); + void ground(char32_t ch); + long parse_esc(const UStr& buf, long pos, long end); // -1: need more + long parse_csi(const UStr& buf, long pos, long end); + long parse_until_st(const UStr& buf, long pos, long end); + void osc(const UStr& body); + std::vector params(const UStr& body, long count, long dflt = 1) const; + void dispatch_csi(const UStr& body, char32_t final_char); + bool in_region() const { return region_top <= cy && cy <= region_bottom; } + void mode(const UStr& body, bool state); + void alt_screen(bool state); + void sgr(const UStr& body); + std::pair margins() const; + bool inside_margins() const; + void erase_display(long mode); + void erase_line(long mode); + void insert_lines(long count); + void delete_lines(long count); + void delete_chars(long count); + void insert_chars(long count); + void erase_chars(long count); + void clamp_cursor(); + TuiVtRow blank_row() const; +}; + +} // namespace detz::tui diff --git a/taicpp/tests/tui_shot.cpp b/taicpp/tests/tui_shot.cpp new file mode 100644 index 0000000..530194a --- /dev/null +++ b/taicpp/tests/tui_shot.cpp @@ -0,0 +1,44 @@ +// tui_shot: headless frames of the demo scene from the C++ port, to diff against python. +// tui_shot [rows cols] plain text, same as tai.py --demo-shot +// tui_shot frame [rows cols] the raw bytes of the first frame (TuiApp.frame_bytes) +// tui_shot cells [rows cols] one line per cell: y x codepoint sgr (compose, no diff) +#include "TuiDemo.h" +#include +#include +#include +#include +using namespace detz::tui; + +int main(int argc, char** argv) { + std::string mode = "text"; + int arg = 1; + if (argc > 1 && (std::string(argv[1]) == "frame" || std::string(argv[1]) == "cells")) { + mode = argv[1]; + arg = 2; + } + int rows = argc > arg ? std::atoi(argv[arg]) : 30; + int cols = argc > arg + 1 ? std::atoi(argv[arg + 1]) : 100; + if (mode == "text") return TuiDemo::demo_shot(std::cout, rows, cols); + TuiApp app; + app.rows = rows; + app.cols = cols; + auto built = TuiDemo::build(app, false); + app.mount(built.first); + app.close_panel(built.first->find("welcome")); + built.second->shell->screen.feed("$ echo hello\r\nhello\r\n$ "); + built.second->stream->screen.feed("stream tick 1\r\nstream tick 2\r\nstream tick 3\r\n"); + if (mode == "frame") { + std::string bytes = app.frame_bytes(); + std::fwrite(bytes.data(), 1, bytes.size(), stdout); + return 0; + } + app.render_text(); // forces layout + TuiCellRows frame = app.compositor.compose(app.root.get(), rows, cols); + for (size_t y = 0; y < frame.size(); y++) { + for (size_t x = 0; x < frame[y].size(); x++) { + std::printf("%zu %zu %u %s\n", y, x, static_cast(frame[y][x].ch), + app.compositor.sgr(frame[y][x].key).substr(1).c_str()); + } + } + return 0; +}