#include "panel.hpp" #include "animation.hpp" #include "calibrator.hpp" #include "logo_shapes.hpp" #include "sprite_shapes.hpp" #include #include #include #include namespace knockthru { namespace { constexpr Color kBackground{18, 20, 24, 248}; constexpr Color kSurface{28, 31, 37}; constexpr Color kSurfaceHover{36, 40, 47}; constexpr Color kRaised{44, 48, 57}; constexpr Color kRaisedHover{54, 59, 69}; constexpr Color kHairline{46, 50, 58}; constexpr Color kText{236, 238, 241}; constexpr Color kDim{150, 156, 168}; constexpr Color kFaint{96, 102, 114}; constexpr Color kAccent{58, 145, 232}; constexpr Color kAccentHover{80, 160, 240}; // Steam's alert yellow, from the mark on its Settings icon when a system update is ready. constexpr Color kUpdate{255, 200, 44}; constexpr Color kKnobOff{196, 200, 208}; constexpr Color kError{240, 110, 100}; constexpr Color kErrorHover{248, 132, 122}; constexpr Color kOnError{40, 14, 12}; // The knocks row is a tint of the accent while knocks act, so the passthrough tile keeps the full accent. constexpr Color kActive{34, 62, 94}; constexpr Color kActiveHover{40, 72, 108}; constexpr Color kTable{120, 92, 70}; // Containers use a 16 px radius. Switches and the style control are pills. constexpr float kRadius = 16; constexpr float kMargin = 40; constexpr const char* kKindTitles[3] = {"Controllers together", "Controller on a surface", "Knock on the headset"}; constexpr const char* kKindOnce[3] = {"Knock the controllers together.", "Knock one controller on a table or wall.", "Knock on the side of the headset with a controller."}; constexpr const char* kKindNames[3] = {"a together knock", "a surface knock", "a headset knock"}; Color faded(Color color, float alpha) { return {color.r, color.g, color.b, static_cast(std::lround(color.a * anim::clamp(alpha)))}; } // Draws a sprite with its anchor at (x, y), `height` pixels tall, turned // counterclockwise by `angle` degrees. void draw_sprite(Canvas& canvas, const sprites::Sprite& sprite, float x, float y, float height, float angle) { const float a = angle * anim::kPi / 180, c = std::cos(a), s = std::sin(a); const auto place = [&](const std::vector>& source) { std::vector> rings; for (const auto& ring : source) { auto& out = rings.emplace_back(); for (std::size_t i = 0; i + 1 < ring.size(); i += 2) { const float px = ring[i] * height, py = ring[i + 1] * height; out.push_back({x + px * c + py * s, y - px * s + py * c}); } } return rings; }; canvas.polygon(place(sprite.body), kText); if (!sprite.accent.empty()) canvas.polygon(place(sprite.accent), kAccent); } // Impact lines that fly outward from (x, y) and fade. `played` runs from 0 to 1. void burst(Canvas& canvas, float x, float y, std::optional played, float scale) { if (!played) return; const float spread = anim::ease_out(*played), fade = 1 - anim::ease_in_out(*played); for (int k = 0; k < 8; ++k) { const float a = k * anim::kPi / 4 + anim::kPi / 8, dx = std::cos(a), dy = std::sin(a); const float inner = (16 + 18 * spread) * scale, outer = inner + (18 - 8 * spread) * scale; const float half = 2 * scale, nx = -dy * half, ny = dx * half; canvas.polygon({{{x + dx * inner + nx, y + dy * inner + ny}, {x + dx * outer + nx, y + dy * outer + ny}, {x + dx * outer - nx, y + dy * outer - ny}, {x + dx * inner - nx, y + dy * inner - ny}}}, faded(kAccent, fade)); } } float head_radius(float height) { return 0.288f * height; } // measured on the controller sprites } // namespace Panel::Panel(const std::filesystem::path& fonts) : bold_(fonts / "NotoSans-SemiBold.ttf"), regular_(fonts / "NotoSans-Regular.ttf"), canvas_(kWidth, kHeight), scratch_(kWidth, kHeight), background_(kWidth, kHeight) { background_.rounded_rect({0, 0, kWidth, kHeight}, 28, kBackground); } bool Panel::hovered(Rect rect) const { return hover_ && rect.contains(hover_->first, hover_->second); } void Panel::switch_control(float right, float cy, bool on, bool enabled, float scale) { const float h = 48 * scale, w = 80 * scale, r = h / 2; const Rect track{right - w, cy - r, w, h}; canvas_.rounded_rect(track, r, !enabled ? kSurfaceHover : on ? kAccent : kRaisedHover); canvas_.circle(on ? track.x + track.w - r : track.x + r, cy, r - 6 * scale, !enabled ? kFaint : on ? kText : kKnobOff); } // A danger button is red when primary, and has a red label otherwise. void Panel::button(Rect rect, const std::string& label, std::optional click, std::optional go_to, bool primary, bool enabled, bool danger) { if (!enabled) { canvas_.rounded_rect(rect, kRadius, kSurfaceHover); canvas_.text(bold_, label, rect.x + rect.w / 2, rect.y + rect.h / 2, 28, kFaint, Align::Center); return; } const bool hover = hovered(rect); const Color fill = primary && danger ? (hover ? kErrorHover : kError) : primary ? (hover ? kAccentHover : kAccent) : (hover ? kRaisedHover : kRaised); canvas_.rounded_rect(rect, kRadius, fill); canvas_.text(bold_, label, rect.x + rect.w / 2, rect.y + rect.h / 2, 28, primary && danger ? kOnError : danger ? kError : kText, Align::Center); targets_.push_back({rect, click, go_to}); } void Panel::header(const std::string& title) { Canvas icon(56, 56); draw_icon(icon); canvas_.draw(icon, static_cast(kMargin), 40); canvas_.text(bold_, title, kMargin + 76, 68, 38, kText); } void Panel::message(const PanelState& state, float y) { for (const auto& line : canvas_.wrap(regular_, state.message, 26, kWidth - 2 * kMargin, 2)) { canvas_.text(regular_, line, kMargin, y, 26, state.message_is_error ? kError : kDim); y += 36; } } void Panel::passthrough_tile(Rect rect, const PanelState& state) { const bool on = state.room_view.value_or(false); const bool broken = !state.room_view_error.empty(); const bool hover = hovered(rect); canvas_.rounded_rect(rect, kRadius, on ? (hover ? kAccentHover : kAccent) : (hover ? kRaisedHover : kRaised)); const float x = rect.x + 36, w = rect.w - 72; canvas_.text(bold_, "Passthrough", x, rect.y + 52, 30, kText); const std::string value = state.busy ? "Switching" : broken ? "Unavailable" : !state.room_view ? "Checking" : on ? "On" : "Off"; canvas_.text(bold_, value, x, rect.y + 160, broken ? 64 : 96, kText); if (broken) { float y = rect.y + rect.h - 92; for (const auto& line : canvas_.wrap(regular_, state.room_view_error, 26, w, 2)) { canvas_.text(regular_, line, x, y, 26, kText); y += 36; } } targets_.push_back({rect, PanelClick{PanelCommand::RoomView}, std::nullopt}); } void Panel::style_control(Rect rect, const PanelState& state) { canvas_.text(bold_, "Style", rect.x, rect.y + 16, 28, kText); const Rect track{rect.x, rect.y + 46, rect.w, 84}; canvas_.rounded_rect(track, track.h / 2, kSurface); const char* names[2] = {"Translucent", "Opaque"}; for (int i = 0; i < 2; ++i) { const Rect half{track.x + 6 + i * (track.w - 12) / 2, track.y + 6, (track.w - 12) / 2, track.h - 12}; const bool selected = state.opaque && *state.opaque == (i == 1); if (selected) { canvas_.rounded_rect(half, half.h / 2, kRaisedHover); } else { if (hovered(half)) canvas_.rounded_rect(half, half.h / 2, kSurfaceHover); if (state.opaque) targets_.push_back({half, PanelClick{PanelCommand::Style}, std::nullopt}); } canvas_.text(bold_, names[i], half.x + half.w / 2, half.y + half.h / 2, 28, selected ? kText : kDim, Align::Center); } const std::string what = !state.opaque ? "" : *state.opaque ? "You see only the room." : "The room shows through your game."; canvas_.text(regular_, what, rect.x, track.y + track.h + 34, 26, kDim); } void Panel::switch_row(Rect rect, std::optional click, bool on, const std::string& title, const std::string& detail, bool enabled, bool divider) { if (enabled && hovered(rect)) canvas_.rounded_rect(rect, kRadius, kSurfaceHover); if (divider) canvas_.rounded_rect({rect.x + 24, rect.y, rect.w - 48, 1.5f}, 0, kHairline); const float title_y = detail.empty() ? rect.y + rect.h / 2 : rect.y + rect.h / 2 - 17; canvas_.text(bold_, canvas_.fit(bold_, title, 30, rect.w - 150), rect.x + 24, title_y, 30, enabled ? kText : kFaint); if (!detail.empty()) canvas_.text(regular_, canvas_.fit(regular_, detail, 26, rect.w - 150), rect.x + 24, rect.y + rect.h / 2 + 21, 26, enabled ? kDim : kFaint); switch_control(rect.x + rect.w - 24, rect.y + rect.h / 2, on, enabled, 1); if (enabled && click) targets_.push_back({rect, click, std::nullopt}); } // A settings row with a title, a detail line, and a button at the right. void Panel::button_row(Rect rect, const std::string& title, const std::string& detail, const std::string& label, std::optional click, std::optional go_to, bool danger, bool divider) { if (divider) canvas_.rounded_rect({rect.x + 24, rect.y, rect.w - 48, 1.5f}, 0, kHairline); canvas_.text(bold_, title, rect.x + 24, rect.y + rect.h / 2 - 17, 30, kText); canvas_.text(regular_, detail, rect.x + 24, rect.y + rect.h / 2 + 21, 26, kDim); button({rect.x + rect.w - 24 - 170, rect.y + rect.h / 2 - 30, 170, 60}, label, click, go_to, false, true, danger); } void Panel::knocks_row(Rect rect, const PanelState& state) { const bool any_kind = state.enabled[0] || state.enabled[1] || state.enabled[2]; const bool active = !state.paused && any_kind; const bool hover = hovered(rect); canvas_.rounded_rect(rect, kRadius, active ? (hover ? kActiveHover : kActive) : (hover ? kRaisedHover : kRaised)); const float cy = rect.y + rect.h / 2; const std::string title = state.paused ? "KnockThru Off" : "KnockThru On"; canvas_.text(bold_, title, rect.x + 28, cy, 38, kText); switch_control(rect.x + rect.w - 28, cy, !state.paused, true, 1.25f); targets_.push_back({rect, PanelClick{PanelCommand::Pause}, std::nullopt}); } void Panel::main_screen(const PanelState& state) { header("KnockThru"); const Rect settings{kMargin + 500 - 160, 36, 160, 64}; button(settings, "Settings", std::nullopt, Screen::Settings); // A dot on Settings while an update waits for its install or its restart, ringed in the background color // so it reads on any fill. if (state.update.status == UpdateView::Status::Available || state.update.status == UpdateView::Status::Installed) { canvas_.circle(settings.x + settings.w - 6, settings.y + 6, 13, kBackground); canvas_.circle(settings.x + settings.w - 6, settings.y + 6, 9, kUpdate); } const float left_w = 500; passthrough_tile({kMargin, 128, left_w, 340}, state); style_control({kMargin, 500, left_w, 170}, state); const float x = kMargin + left_w + 48, w = kWidth - kMargin - x; canvas_.rounded_rect({x, 40, w, 648}, kRadius, kSurface); float y = 40; knocks_row({x + 8, y + 8, w - 16, 104}, state); y += 112; y += 24; for (int k = 0; k < 3; ++k, y += 120) switch_row({x, y, w, 120}, PanelClick{PanelCommand::Kind, static_cast(k)}, state.enabled[k], kKindTitles[k], "", !state.paused, false); y += 8; if (state.app_key.empty()) { switch_row({x, y, w, 128}, std::nullopt, false, "This game", "No game running", false, true); } else { const auto name = state.app_name.empty() ? state.app_key : state.app_name; switch_row({x, y, w, 128}, PanelClick{PanelCommand::BlockApp}, !state.app_blocked, "Knocks in " + name, state.app_blocked ? "Off for this game only" : "On", true, true); } // The tiles already show what worked, so the main screen only shows errors. if (state.message_is_error) message(state, 718); } // One bar per step at the top right. Steps up to `current` (from 1) are filled. void Panel::steps(int current, int total) { const float w = 44, gap = 10, right = kWidth - kMargin, y = 64; const float left = right - total * w - (total - 1) * gap; canvas_.text(regular_, "Step " + std::to_string(current) + " of " + std::to_string(total), left - 20, y + 4, 24, kDim, Align::Right); for (int i = 0; i < total; ++i) canvas_.rounded_rect({left + i * (w + gap), y, w, 8}, 4, i < current ? kAccent : kRaised); } // One knock kind on the shared knock timeline, fit into `box`. The layout is on // a 340 by 240 grid, the same as the README's knock kind cards. void Panel::knock_scene(KnockKind kind, Rect box, float t, int knocks) { const float s = std::min(box.w / 340, box.h / 240); const float ox = box.x + (box.w - 340 * s) / 2, oy = box.y + (box.h - 240 * s) / 2; const auto X = [&](float x) { return ox + x * s; }; const auto Y = [&](float y) { return oy + y * s; }; const float gap = anim::knock_motion(t, knocks); const auto played = anim::impact(t, knocks); // A counted calibration knock buzzes at once. An action buzzes after the 0.4 s wait. const float action = anim::knock_time(knocks - 1) + (knocks == 1 ? 0.05f : 0.4f); const float shake = anim::buzz(t, action, 1) * s; if (kind == KnockKind::Together) { const float h = 150, cx = 170, cy = 105; const float offset = head_radius(h) * 0.95f + 30 * gap, tilt = 30 + 10 * gap; burst(canvas_, X(cx), Y(cy), played, s); draw_sprite(canvas_, sprites::kControllerLeft, X(cx - offset) + shake, Y(cy), h * s, -tilt); draw_sprite(canvas_, sprites::kControllerRight, X(cx + offset) - shake, Y(cy), h * s, tilt); } else if (kind == KnockKind::Surface) { const float h = 150, cx = 170, table_y = 200; burst(canvas_, X(cx), Y(table_y), played, s); draw_sprite(canvas_, sprites::kControllerRight, X(cx) + shake, Y(table_y - head_radius(h) * 0.95f - 45 * gap), h * s, 135 - 12 * gap); canvas_.rounded_rect({X(cx - 130), Y(table_y), 260 * s, 16 * s}, 3 * s, kTable); } else { const float h = 130, cx = 170, cy = 120, hw = 160, hx = cx + 70, edge = hx - hw / 2; burst(canvas_, X(edge), Y(cy), played, s); draw_sprite(canvas_, sprites::kHeadset, X(hx), Y(cy), hw / sprites::kHeadset.aspect * s, 0); draw_sprite(canvas_, sprites::kControllerLeft, X(edge - head_radius(h) * 0.95f - 30 * gap) + shake, Y(cy), h * s, -30 - 8 * gap); } } // Step 1: three cards, each knocking twice on a loop, as knocks work outside calibration. void Panel::pick_screen(const PanelState& state) { button({kMargin, 36, 140, 64}, "Back", std::nullopt, Screen::Settings); canvas_.text(bold_, "Calibrate", kMargin + 172, 68, 38, kText); steps(1, 4); const float t = static_cast(std::fmod(now_, 2.6)); // The cards fill the panel, and make room when the Saved banner shows. const bool banner = state.calibration_saved && !saved_dismissed_; // A saved kind that is turned off, or KnockThru paused, would leave knocks doing nothing. const int saved = state.calibration_saved ? static_cast(*state.calibration_saved) : 0; const std::string saved_note = !banner ? "" : state.paused ? "KnockThru is off. Turn it on from the main screen to use knocks." : !state.enabled[saved] ? std::string(kKindTitles[saved]) + " is off. Turn it on from the main screen." : "KnockThru now listens for knocks like the ones you tried."; const float card_h = banner ? 420 : 560; for (int k = 0; k < 3; ++k) { const Rect card{kMargin + k * 384.0f, 128, 352, card_h}; canvas_.rounded_rect(card, kRadius, hovered(card) ? kSurfaceHover : kSurface); knock_scene(static_cast(k), {card.x + 16, card.y + 16, card.w - 32, card_h - 100}, t, 2); canvas_.text(bold_, kKindTitles[k], card.x + card.w / 2, card.y + card_h - 44, 28, kText, Align::Center); targets_.push_back({card, std::nullopt, Screen::Learn, static_cast(k)}); } if (banner) { const Rect done{kMargin, 572, kWidth - 2 * kMargin, 120}; canvas_.rounded_rect(done, kRadius, kActive); canvas_.text(bold_, "Saved", done.x + 32, done.y + done.h / 2 - 20, 38, kText); canvas_.text(regular_, canvas_.fit(regular_, saved_note, 26, done.w - 260), done.x + 32, done.y + done.h / 2 + 24, 26, kText); button({done.x + done.w - 32 - 170, done.y + 30, 170, 60}, "Done", std::nullopt, Screen::Settings, true); } else if (state.message_is_error) { message(state, 730); } } // A check mark drawn as two bars on an accent circle. `pop` scales it from 0 to 1. void Panel::check_mark(float cx, float cy, float radius, float pop) { canvas_.circle(cx, cy, radius * pop, kAccent); const float s = pop * radius / 34; canvas_.polygon({{{cx - 16 * s, cy}, {cx - 6 * s, cy + 11 * s}, {cx - 6 * s, cy + 20 * s}, {cx - 21 * s, cy + 5 * s}}}, kText); canvas_.polygon({{{cx - 6 * s, cy + 11 * s}, {cx + 16 * s, cy - 13 * s}, {cx + 21 * s, cy - 8 * s}, {cx - 6 * s, cy + 20 * s}}}, kText); } // Step 2: how to knock for calibration, one knock at a time. void Panel::learn_screen(const PanelState& state) { const int k = static_cast(learn_kind_); button({kMargin, 36, 140, 64}, "Back", std::nullopt, Screen::Pick); canvas_.text(bold_, kKindTitles[k], kMargin + 172, 68, 38, kText); steps(2, 4); const Rect box{kMargin, 128, 540, 560}; canvas_.rounded_rect(box, kRadius, kSurface); knock_scene(learn_kind_, {box.x + 30, box.y + 60, box.w - 60, 440}, static_cast(std::fmod(now_, 2.0)), 1); const float x = box.x + box.w + 48, w = kWidth - kMargin - x; canvas_.text(bold_, "Wait for the ring, then knock once.", x, 190, 36, kText); float y = 250; for (const std::string& line : {std::string(kKindOnce[k]), std::string("Hold still while the ring fills."), std::string("Do it 10 times. One buzz means it counted."), std::string("Knock as hard as you would to switch passthrough.")}) { for (const auto& part : canvas_.wrap(regular_, line, 28, w, 2)) { canvas_.text(regular_, part, x, y, 28, kDim); y += 40; } y += 14; } button({kWidth - kMargin - 240, 616, 240, 72}, "Start", PanelClick{PanelCommand::Calibrate, learn_kind_}, std::nullopt, true); if (state.message_is_error) message(state, 730); } // Step 3: counts single knocks, with each one's strength. void Panel::record_screen(const PanelState& state, const CalibrationView& view) { const int k = static_cast(view.kind); button({kMargin, 36, 160, 64}, "Cancel", PanelClick{PanelCommand::CancelCalibration, view.kind}, std::nullopt); canvas_.text(bold_, "Recording", kMargin + 192, 68, 38, kText); steps(3, 4); const Rect box{kMargin, 128, 420, 560}; canvas_.rounded_rect(box, kRadius, kSurface); knock_scene(view.kind, {box.x + 20, box.y + 40, box.w - 40, 340}, static_cast(std::fmod(now_, 2.0)), 1); canvas_.text(bold_, kKindTitles[k], box.x + box.w / 2, box.y + 440, 28, kText, Align::Center); canvas_.text(regular_, "Wait for the ring, then knock once", box.x + box.w / 2, box.y + 486, 26, kDim, Align::Center); const float x = box.x + box.w + 48; const int count = static_cast(view.peaks.size()); canvas_.text(bold_, std::to_string(count) + " of " + std::to_string(view.wanted), x, 190, 80, kText); canvas_.text(regular_, std::to_string(view.seconds_left) + " s left", kWidth - kMargin, 190, 28, kDim, Align::Right); // One bar per knock above its dot, by how fast the controller moved into it. // A full bar is 2 m/s, a little faster than the hardest recorded knocks. const float since = static_cast(now_ - view.counted_t); const float base = 470, tallest = 180, spacing = 64, r = 18; for (int i = 0; i < view.wanted; ++i) { const float cx = x + r + i * spacing; const bool latest = i == count - 1; if (i < count) { const float grow = latest ? anim::ease_out(since / 0.35f) : 1; const double speed = i < static_cast(view.speeds.size()) ? view.speeds[i] : 0; const float h = std::max(8.0f, static_cast(std::min(1.0, speed / 2.0)) * tallest * grow); canvas_.rounded_rect({cx - 14, base - h, 28, h}, 8, kAccent); const float pop = latest ? anim::ease_out_back(since / 0.25f) : 1; canvas_.circle(cx, base + 44, r * pop, kAccent); if (latest && since < 0.5f) { const float p = since / 0.5f; const float ring = r + 22 * anim::ease_out(p); canvas_.arc(cx, base + 44, ring - 3, ring, 0, 7, faded(kAccent, 1 - p)); } } else { canvas_.rounded_rect({cx - 14, base - 8, 28, 8}, 4, kRaised); canvas_.arc(cx, base + 44, r - 3, r, 0, 7, kRaisedHover); } } // Pacing: a ring fills while knocks cannot count yet. First for the get-ready // time after Start, then for a second after each counted knock. const bool getting_ready = count == 0; const float cy = 610, ready = getting_ready ? anim::clamp(static_cast((now_ - view.started_t) / Calibrator::kGetReady)) : anim::clamp(static_cast((now_ - view.knocked_t) / Calibrator::kPace)); canvas_.arc(x + 26, cy, 20, 28, 0, 7, kRaised); if (ready < 1) canvas_.arc(x + 26, cy, 20, 28, anim::kPi / 2 - ready * 2 * anim::kPi, anim::kPi / 2, kAccent); else canvas_.circle(x + 26, cy, 28, kAccent); canvas_.text(bold_, ready >= 1 ? "Knock" : getting_ready ? "Get ready" : "Wait", x + 74, cy, 32, kText); // A knock that did not count gets a hint for a few seconds: too soon, too // light, or another kind. The newest one shows. const double latest = std::max({view.too_soon_t, view.too_light_t, view.ignored ? view.ignored_t : -1.0}); const float hint = static_cast(now_ - latest); if (latest >= 0 && hint < 3) { const float shown = std::min(anim::ease_out(hint / 0.25f), 1 - anim::ease_in_out((hint - 2.5f) / 0.5f)); const auto text = latest == view.too_soon_t ? std::string("Too soon. Wait for the ring.") : latest == view.too_light_t ? std::string("Too light. Knock a little harder.") : std::string("That looked like ") + kKindNames[static_cast(*view.ignored)] + "."; canvas_.text(regular_, text, x, 670 + 8 * (1 - shown), 26, faded(kError, shown)); } else if (state.message_is_error) { message(state, 670); } } // Step 4: a double knock at the new thresholds, without switching passthrough. void Panel::try_screen(const PanelState& state, const CalibrationView& view) { const int k = static_cast(view.kind); button({kMargin, 36, 160, 64}, "Cancel", PanelClick{PanelCommand::CancelCalibration, view.kind}, std::nullopt); canvas_.text(bold_, "Try it", kMargin + 192, 68, 38, kText); steps(4, 4); const Rect box{kMargin, 128, 420, 500}; canvas_.rounded_rect(box, kRadius, kSurface); knock_scene(view.kind, {box.x + 20, box.y + 40, box.w - 40, 340}, static_cast(std::fmod(now_, 2.6)), 2); canvas_.text(bold_, kKindTitles[k], box.x + box.w / 2, box.y + 440, 28, kText, Align::Center); const float x = box.x + box.w + 48, w = kWidth - kMargin - x; canvas_.text(bold_, "Knock twice", x, 190, 36, kText); canvas_.text(regular_, "All " + std::to_string(view.peaks.size()) + " knocks recorded.", x, 240, 28, kDim); canvas_.text(regular_, "Knock the way you would to switch passthrough.", x, 280, 28, kDim); canvas_.text(regular_, "Passthrough stays as it is.", x, 320, 28, kDim); const Rect status{x, 368, w, 240}; const float since = static_cast(now_ - view.trial_t); if (!view.trial_passed) { canvas_.rounded_rect(status, kRadius, kSurface); const float pulse = 0.5f + 0.5f * std::sin(static_cast(now_) * 2 * anim::kPi / 1.4f); canvas_.circle(status.x + 56, status.y + status.h / 2, 14 + 6 * pulse, faded(kAccent, 0.5f + 0.5f * pulse)); canvas_.text(bold_, "Listening", status.x + 96, status.y + status.h / 2, 34, kText); } else if (*view.trial_passed) { canvas_.rounded_rect(status, kRadius, kActive); const float cx = status.x + 64, cy = status.y + status.h / 2; check_mark(cx, cy, 34, anim::ease_out_back(since / 0.3f)); canvas_.text(bold_, "That worked", cx + 64, cy - 22, 34, kText); canvas_.text(regular_, "Save to keep this calibration.", cx + 64, cy + 24, 26, kText); } else { canvas_.rounded_rect(status, kRadius, kSurface); const float shake = anim::buzz(since, 0, 2) * 2; canvas_.text(bold_, "Not quite", status.x + 40 + shake, status.y + 70, 34, kError); float y = status.y + 124; for (const auto& line : canvas_.wrap(regular_, "Heard " + view.trial_heard + ". Try again, or record your knocks again.", 26, status.w - 80, 3)) { canvas_.text(regular_, line, status.x + 40, y, 26, kDim); y += 36; } } button({kMargin, 656, 260, 72}, "Record again", PanelClick{PanelCommand::Calibrate, view.kind}, std::nullopt); const bool passed = view.trial_passed.value_or(false); button({kWidth - kMargin - 260, 656, 260, 72}, "Save", PanelClick{PanelCommand::SaveCalibration, view.kind}, std::nullopt, true, passed); if (state.message_is_error) message(state, 610); } void Panel::settings_screen(const PanelState& state) { button({kMargin, 36, 140, 64}, "Back", std::nullopt, Screen::Main); canvas_.text(bold_, "Settings", kMargin + 172, 68, 38, kText); update_control(state); const float top = 128, w = kWidth - 2 * kMargin, row = 92; canvas_.rounded_rect({kMargin, top, w, 6 * row}, kRadius, kSurface); button_row({kMargin, top, w, row}, "Knocks getting missed?", "KnockThru learns how hard you knock", "Calibrate", std::nullopt, Screen::Pick, false, false); switch_row({kMargin, top + row, w, row}, PanelClick{PanelCommand::Sounds}, state.sounds, "Sounds", "Click on a triple knock and on buttons", true, true); switch_row({kMargin, top + 2 * row, w, row}, PanelClick{PanelCommand::BarIcon}, state.classic_bar_icon, "Classic bar icon", "Show the logo colors in the dashboard bar", true, true); Canvas icon(64, 64); draw_icon(icon, state.classic_bar_icon ? IconStyle::BarColor : IconStyle::BarWhite); canvas_.draw(icon, static_cast(kMargin + w - 24 - 80 - 40 - 64), static_cast(top + 2 * row + (row - 64) / 2)); button_row({kMargin, top + 3 * row, w, row}, "Guided tour", "Learn the knocks again", "Replay", PanelClick{PanelCommand::StartTour}, std::nullopt, false); button_row({kMargin, top + 4 * row, w, row}, "Something wrong?", "Tell the developer on GitHub", "Report", PanelClick{PanelCommand::ShowReport}, Screen::Report, false); button_row({kMargin, top + 5 * row, w, row}, "Factory reset", "Delete settings and calibration", "Reset", std::nullopt, Screen::Reset, true); if (state.message_is_error) message(state, top + 6 * row + 40); } // The version and the update button, at the top right of Settings. void Panel::update_control(const PanelState& state) { using Status = UpdateView::Status; const auto& update = state.update; const float right = kWidth - kMargin, w = 250, h = 64, y = 36; const Rect rect{right - w, y, w, h}; std::string status = "Version " + state.version, label = "Check for updates"; bool primary = false, enabled = true; std::optional click = PanelClick{PanelCommand::CheckUpdate}; switch (update.status) { case Status::Idle: break; case Status::Checking: label = "Checking"; enabled = false; break; case Status::Current: status = "Version " + state.version + " is the latest"; break; case Status::Available: status = "You have version " + state.version; label = "Install " + update.latest; click = PanelClick{PanelCommand::InstallUpdate}; break; case Status::Installing: label = "Installing " + std::to_string(std::lround(state.update_progress * 100)) + "%"; break; case Status::Installed: status = "Version " + update.latest + " is installed"; label = "Restart"; primary = true; // the filled bar turns into the blue Restart button click = PanelClick{PanelCommand::RestartForUpdate}; break; case Status::Restarting: status = "Version " + update.latest + " is installed"; label = "Restarting"; enabled = false; break; case Status::Updated: status = "Updated to version " + state.version; break; case Status::Failed: status = update.latest.empty() ? "Check failed" : "Install failed"; label = "Try again"; break; } canvas_.text(regular_, status, rect.x - 24, y + h / 2, 26, update.status == Status::Failed ? kError : kDim, Align::Right); // The alert mark ties the yellow dot on the Settings button to this row. It is as tall as the text's capitals. if (update.status == Status::Available || update.status == Status::Installed) alert_mark(rect.x - 24 - canvas_.measure(regular_, status, 26) - 14, y + h / 2, 1.05f, kUpdate); if (update.status == Status::Installing) progress_button(rect, label, state.update_progress); else button(rect, label, click, std::nullopt, primary, enabled); } // A button that fills from the left with a blue tint as `progress` goes from 0 to 1. It takes no clicks. void Panel::progress_button(Rect rect, const std::string& label, float progress) { canvas_.rounded_rect(rect, kRadius, kRaised); // The button's outline with every point right of the fill's edge pulled back to it. The outline // is convex, so this is the button shape cut off at the edge. const float edge = rect.x + rect.w * std::clamp(progress, 0.0f, 1.0f); if (edge > rect.x) { std::vector outline; const std::array, 4> corners{{{rect.x + rect.w - kRadius, rect.y + kRadius, -anim::kPi / 2}, {rect.x + rect.w - kRadius, rect.y + rect.h - kRadius, 0}, {rect.x + kRadius, rect.y + rect.h - kRadius, anim::kPi / 2}, {rect.x + kRadius, rect.y + kRadius, anim::kPi}}}; for (const auto& [cx, cy, start] : corners) for (int i = 0; i <= 8; ++i) { const float a = start + anim::kPi / 2 * static_cast(i) / 8; outline.push_back({std::min(cx + kRadius * std::cos(a), edge), cy + kRadius * std::sin(a)}); } canvas_.polygon({outline}, Color{kAccent.r, kAccent.g, kAccent.b, 110}); } canvas_.text(bold_, label, rect.x + rect.w / 2, rect.y + rect.h / 2, 28, kText, Align::Center); } // Steam's alert mark from its Settings icon, a tapered bar over a dot, centered on (cx, cy). // At scale 1 it is about 17 px tall. void Panel::alert_mark(float cx, float cy, float s, Color color) { const float top = cy - 8.37f * s; canvas_.polygon({{{cx - 3.37f * s, top}, {cx + 3.37f * s, top}, {cx + 2.22f * s, top + 8 * s}, {cx - 2.22f * s, top + 8 * s}}}, color); canvas_.circle(cx, top + 13.87f * s, 2.87f * s, color); } // The report card over Settings. A phone held to a lens scans the QR code and // opens a page the headset serves, which copies the full report and opens the // GitHub form. Without a network, the code opens the form directly, and Save to // headset writes the report to a file for the person to attach. void Panel::report_card(const PanelState& state) { targets_.clear(); canvas_.rounded_rect({0, 0, kWidth, kHeight}, 28, {0, 0, 0, 150}); const Rect card{80, 60, kWidth - 160, 650}; canvas_.rounded_rect(card, kRadius, kSurface); const float box = 380, x = card.x + 48, text_w = card.w - 96 - box - 40; canvas_.text(bold_, "Report a problem", x, card.y + 72, 38, kText); const auto steps = state.report_full ? std::array{"Scan this code with your phone.", "Tap Copy report and open GitHub.", "Paste, say what went wrong, and tap Create."} : std::array{"Scan this code with your phone.", "Sign in to GitHub and say what went wrong.", "Attach the saved report, then tap Create."}; float y = card.y + 150; for (int i = 0; i < 3; ++i) { canvas_.circle(x + 22, y, 22, kAccent); canvas_.text(bold_, std::to_string(i + 1), x + 22, y, 26, kText, Align::Center); const auto lines = canvas_.wrap(regular_, steps[i], 28, text_w - 64, 2); for (std::size_t k = 0; k < lines.size(); ++k) canvas_.text(regular_, lines[k], x + 64, y + k * 40, 28, kText); y += 40 * lines.size() + 36; } const std::string hint = state.report_full ? "Hold the phone up to a lens. Use the Frame's Wi-Fi." : "The Frame is offline. Save the report below to attach it."; for (const auto& line : canvas_.wrap(regular_, hint, 26, text_w, 3)) { canvas_.text(regular_, line, x, y, 26, kDim); y += 36; } // Knock problems need the motion itself, so the report can carry a recording. const Rect record{x, card.y + 420, 240, 64}; const bool recording = state.recording_left >= 0; const std::string record_label = recording ? "Recording " + std::to_string(state.recording_left) + " s" : state.recorded ? "Record again" : "Record knocks"; button(record, record_label, PanelClick{PanelCommand::RecordKnocks}, std::nullopt, false, !recording); std::string record_note = "Knocks missed or wrong? Record 20 s of them."; if (recording) record_note = "Knock the way that goes wrong. Heard " + std::to_string(state.recording_knocks) + " so far."; else if (state.recorded) record_note = state.report_full ? "Recording added. Your phone gets it in one report file." : "Recording saved as knockthru-knocks.csv.gz."; const float record_x = record.x + record.w + 24; float ry = record.y + record.h / 2 - 16; for (const auto& line : canvas_.wrap(regular_, record_note, 24, text_w - record.w - 24, 2)) { canvas_.text(regular_, line, record_x, ry, 24, kDim); ry += 32; } // The code on white with a 4-module quiet zone, in whole pixels per module. const Rect white{card.x + card.w - 48 - box, card.y + 48, box, box}; canvas_.rounded_rect(white, kRadius, {255, 255, 255}); if (!state.report_url.empty()) { const auto qr = qrcodegen::QrCode::encodeText(state.report_url.c_str(), qrcodegen::QrCode::Ecc::MEDIUM); const int size = qr.getSize(), module = static_cast(box) / (size + 8); const float origin_x = std::floor(white.x + (box - module * size) / 2); const float origin_y = std::floor(white.y + (box - module * size) / 2); // Each run of dark modules in a row is one rectangle, so no seams show between them. // Radius 0.5 fills solid. Radius 0 gives every pixel half coverage in rounded_rect. for (int my = 0; my < size; ++my) { for (int mx = 0; mx < size;) { if (!qr.getModule(mx, my)) { ++mx; continue; } int end = mx; while (end < size && qr.getModule(end, my)) ++end; canvas_.rounded_rect({origin_x + mx * module, origin_y + my * module, static_cast((end - mx) * module), static_cast(module)}, 0.5f, {0, 0, 0}); mx = end; } } } // Without a phone, the file is the way to get the full report into an issue. const float by = card.y + card.h - 48 - 72; const bool saved = !state.report_saved.empty(); // Offline, the hint above already points here. std::string note = state.report_full ? "No phone? Save the report, then attach it to an issue." : ""; if (saved) note = "Saved as knockthru-report.txt in your home folder."; if (!state.report_error.empty()) note = "Not saved: " + state.report_error; // The note sits between the two buttons, next to the one it explains. const float note_x = x + 300 + 28, note_w = card.x + card.w - 48 - 240 - 28 - note_x; const auto note_lines = note.empty() ? std::vector{} : canvas_.wrap(regular_, note, 24, note_w, 2); float ny = by + 36 - 16 * (static_cast(note_lines.size()) - 1); for (const auto& line : note_lines) { canvas_.text(regular_, line, note_x, ny, 24, state.report_error.empty() ? kDim : kError); ny += 32; } button({x, by, 300, 72}, saved ? "Saved" : "Save to headset", PanelClick{PanelCommand::SaveReport}, std::nullopt, false, !saved); button({card.x + card.w - 48 - 240, by, 240, 72}, "Done", PanelClick{PanelCommand::CloseReport}, Screen::Settings, true); } // A confirm card over the screen below it. Only its two buttons respond. void Panel::confirm_card(const std::string& title, const std::string& text, const std::string& cancel, Screen cancel_to, const std::string& confirm, PanelClick confirm_click, bool danger) { targets_.clear(); canvas_.rounded_rect({0, 0, kWidth, kHeight}, 28, {0, 0, 0, 150}); const Rect card{220, 190, kWidth - 440, 390}; canvas_.rounded_rect(card, kRadius, kSurface); canvas_.text(bold_, title, card.x + 48, card.y + 72, 38, kText); float y = card.y + 140; for (const auto& line : canvas_.wrap(regular_, text, 28, card.w - 96, 3)) { canvas_.text(regular_, line, card.x + 48, y, 28, kText); y += 40; } const float by = card.y + card.h - 48 - 72; button({card.x + 48, by, 240, 72}, cancel, std::nullopt, cancel_to); button({card.x + card.w - 48 - 240, by, 240, 72}, confirm, confirm_click, Screen::Main, true, true, danger); } // The update result over the screen below it. Only OK responds. void Panel::update_card(const UpdateNotice& notice) { targets_.clear(); canvas_.rounded_rect({0, 0, kWidth, kHeight}, 28, {0, 0, 0, 150}); // The card grows with the text, from one to three lines. const float w = kWidth - 440; const auto lines = canvas_.wrap(regular_, notice.text, 28, w - 96, 3); const float h = 290 + 40 * static_cast(lines.size()); const Rect card{220, (kHeight - h) / 2, w, h}; canvas_.rounded_rect(card, kRadius, kSurface); const float cx = card.x + 48 + 30, cy = card.y + 78; if (notice.failed) { canvas_.circle(cx, cy, 30, kError); alert_mark(cx, cy, 1.6f, kOnError); } else { check_mark(cx, cy, 30, 1); } canvas_.text(bold_, notice.failed ? "Update failed" : "KnockThru updated", cx + 52, cy, 38, kText); float y = card.y + 156; for (const auto& line : lines) { canvas_.text(regular_, line, card.x + 48, y, 28, kText); y += 40; } const float by = card.y + card.h - 48 - 72; button({card.x + card.w - 48 - 200, by, 200, 72}, "OK", PanelClick{PanelCommand::DismissUpdateNotice}, std::nullopt, true); } // Back and Next along the bottom, with Skip. The first step has Skip and Start, the last Back and Done. // Next stays off until the step is complete. Skip always works, after a confirm card. void Panel::tour_buttons(bool first, bool last, bool complete) { const float y = 656, h = 72; const Rect go{kWidth - kMargin - 200, y, 200, h}; if (first) { button({kMargin, y, 180, h}, "Skip", std::nullopt, Screen::SkipTour); button(go, "Start", PanelClick{PanelCommand::TourNext}, std::nullopt, true); return; } button({kMargin, y, 180, h}, "Back", PanelClick{PanelCommand::TourBack}, std::nullopt); if (last) { button(go, "Done", PanelClick{PanelCommand::TourSkip}, std::nullopt, true); return; } button({go.x - 24 - 160, y, 160, h}, "Skip", std::nullopt, Screen::SkipTour); button(go, "Next", PanelClick{PanelCommand::TourNext}, std::nullopt, true, complete); } // The double and triple knock steps: the knock on a loop at the left, and at the // right what to do and whether it worked. Knocks here really switch. void Panel::tour_knock_step(const PanelState& state, const TourView& tour) { const bool triple = tour.step == TourView::Step::Triple; canvas_.text(bold_, triple ? "Knock three times" : "Knock twice", kMargin, 68, 38, kText); const Rect box{kMargin, 128, 420, 500}; canvas_.rounded_rect(box, kRadius, kSurface); // The scene shows each knock kind left on in turn, one per loop. std::vector shown; for (int k = 0; k < 3; ++k) if (state.enabled[k]) shown.push_back(static_cast(k)); if (shown.empty()) shown = {KnockKind::Together, KnockKind::Surface, KnockKind::Headset}; const double loop = 2.6; const auto kind = shown[static_cast(now_ / loop) % shown.size()]; knock_scene(kind, {box.x + 20, box.y + 40, box.w - 40, 340}, static_cast(std::fmod(now_, loop)), triple ? 3 : 2); canvas_.text(bold_, kKindTitles[static_cast(kind)], box.x + box.w / 2, box.y + 440, 28, kText, Align::Center); const float x = box.x + box.w + 48, w = kWidth - kMargin - x; float y = 190; for (const auto& line : canvas_.wrap(bold_, triple ? "Knock three times to switch between translucent and opaque" : "Knock twice to switch passthrough", 36, w, 2)) { canvas_.text(bold_, line, x, y, 36, kText); y += 48; } y += 4; // Lists only the kinds left on, since the others do nothing. static constexpr const char* kWays[3] = {"controllers together", "a controller on a table", "a controller on the headset"}; std::vector ways; for (int k = 0; k < 3; ++k) if (state.enabled[k]) ways.push_back(kWays[k]); std::string list; for (std::size_t i = 0; i < ways.size(); ++i) list += (i == 0 ? "" : i + 1 == ways.size() ? (ways.size() == 2 ? " or " : ", or ") : ", ") + ways[i]; const std::string how = ways.size() > 1 ? "Any of these work: " + list + "." : "Use " + list + "."; for (const auto& line : canvas_.wrap(regular_, how, 28, w, 3)) { canvas_.text(regular_, line, x, y, 28, kDim); y += 40; } const Rect status{x, 408, w, 220}; const double heard_t = triple ? tour.triple_t : tour.double_t; const float cy = status.y + status.h / 2; if (!triple && !state.room_view_error.empty()) { canvas_.rounded_rect(status, kRadius, kSurface); canvas_.text(bold_, "Passthrough unavailable", status.x + 40, status.y + 64, 34, kError); float line_y = status.y + 118; for (const auto& line : canvas_.wrap(regular_, state.room_view_error, 26, status.w - 80, 2)) { canvas_.text(regular_, line, status.x + 40, line_y, 26, kDim); line_y += 36; } } else if (heard_t < 0) { canvas_.rounded_rect(status, kRadius, kSurface); const float pulse = 0.5f + 0.5f * std::sin(static_cast(now_) * 2 * anim::kPi / 1.4f); canvas_.circle(status.x + 56, cy, 14 + 6 * pulse, faded(kAccent, 0.5f + 0.5f * pulse)); canvas_.text(bold_, "Listening", status.x + 96, cy, 34, kText); } else { canvas_.rounded_rect(status, kRadius, kActive); const float cx = status.x + 64; check_mark(cx, cy, 34, anim::ease_out_back(static_cast(now_ - heard_t) / 0.3f)); std::string title, detail; if (triple) { title = !state.opaque ? "Style switched" : *state.opaque ? "Style is opaque" : "Style is translucent"; // A triple knock while passthrough is off sets the style it opens in. detail = state.room_view == false ? "Passthrough is off. It opens in this style." : "Knock three times again to switch back."; } else { const bool on = state.room_view.value_or(false); title = state.busy ? "Switching" : on ? "Passthrough is on" : "Passthrough is off"; detail = std::string("Knock twice again to turn it ") + (on ? "off." : "on."); } canvas_.text(bold_, canvas_.fit(bold_, title, 34, status.w - 150), cx + 64, cy - 22, 34, kText); canvas_.text(regular_, canvas_.fit(regular_, detail, 26, status.w - 150), cx + 64, cy + 24, 26, kText); } } // The first-run tour: welcome, the knock kinds, calibration, the double knock, the triple knock, and done. void Panel::tour_screen(const PanelState& state, const TourView& tour) { using Step = TourView::Step; steps(static_cast(tour.step) + 1, 6); const float cx = kWidth / 2.0f; switch (tour.step) { case Step::Welcome: { Canvas icon(152, 152); draw_icon(icon); canvas_.draw(icon, (kWidth - 152) / 2, 150); canvas_.text(bold_, "Welcome to KnockThru", cx, 370, 48, kText, Align::Center); canvas_.text(regular_, "Knock to switch passthrough, without the side button.", cx, 440, 30, kDim, Align::Center); canvas_.text(regular_, "This tour takes a few minutes.", cx, 484, 30, kDim, Align::Center); tour_buttons(true, false, true); break; } case Step::Double: case Step::Triple: tour_knock_step(state, tour); tour_buttons(false, false, (tour.step == Step::Double ? tour.double_t : tour.triple_t) >= 0); break; case Step::Kinds: { canvas_.text(bold_, "Pick your knocks", kMargin, 68, 38, kText); const float w = kWidth - 2 * kMargin; canvas_.rounded_rect({kMargin, 128, w, 352}, kRadius, kSurface); for (int k = 0; k < 3; ++k) switch_row({kMargin, 136 + k * 112.0f, w, 112}, PanelClick{PanelCommand::Kind, static_cast(k)}, state.enabled[k], kKindTitles[k], kKindOnce[k], true, k > 0); if (!state.enabled[0] && !state.enabled[1] && !state.enabled[2]) canvas_.text(regular_, "Turn on at least one knock to continue.", kMargin, 530, 28, kError); tour_buttons(false, false, state.enabled[0] || state.enabled[1] || state.enabled[2]); break; } case Step::Calibrate: { canvas_.text(bold_, "Calibrate", kMargin, 68, 38, kText); const float t = static_cast(std::fmod(now_, 2.6)); const float card_h = 500; bool complete = true; // every kind that is on has a calibration for (int k = 0; k < 3; ++k) { const Rect card{kMargin + k * 384.0f, 128, 352, card_h}; // A kind turned off on the previous step has nothing to calibrate. const bool on = state.enabled[k]; canvas_.rounded_rect(card, kRadius, on && hovered(card) ? kSurfaceHover : kSurface); knock_scene(static_cast(k), {card.x + 16, card.y + 16, card.w - 32, card_h - 100}, t, 2); if (!on) canvas_.rounded_rect(card, kRadius, faded(kBackground, 0.7f)); canvas_.text(bold_, kKindTitles[k], card.x + card.w / 2, card.y + card_h - 60, 28, on ? kText : kFaint, Align::Center); if (on && !tour.calibrated[k]) complete = false; const std::string note = !on ? "Off" : tour.calibrated[k] ? "Calibrated" : "Select to calibrate"; canvas_.text(regular_, note, card.x + card.w / 2, card.y + card_h - 24, 24, tour.calibrated[k] ? kAccent : kFaint, Align::Center); if (tour.calibrated[k]) check_mark(card.x + card.w - 40, card.y + 40, 22, 1); if (on) targets_.push_back({card, std::nullopt, Screen::Learn, static_cast(k)}); } tour_buttons(false, false, complete); break; } case Step::Done: check_mark(cx, 230, 56, 1); canvas_.text(bold_, "You're set", cx, 340, 48, kText, Align::Center); canvas_.text(regular_, "Replay this tour any time from Settings.", cx, 410, 30, kDim, Align::Center); tour_buttons(false, true, true); break; } // Errors go in the strip under the buttons, which every step leaves clear. if (state.message_is_error) canvas_.text(regular_, canvas_.fit(regular_, state.message, 24, kWidth - 2 * kMargin), kMargin, 748, 24, kError); } const Canvas& Panel::render(const PanelState& state, std::optional> hover, double now) { hover_ = hover; now_ = now; targets_.clear(); canvas_ = background_; const auto& calibration = state.calibration; if (calibration) { saved_dismissed_ = false; } else if (was_calibrating_) { screen_ = Screen::Pick; // canceled or saved } was_calibrating_ = calibration.has_value(); if (state.tour && !was_touring_) screen_ = Screen::Main; // just started if (!state.tour && was_touring_) screen_ = Screen::Main; // finished or skipped was_touring_ = state.tour.has_value(); // The tour's Calibrate step hands over to the Learn screen, whose Back returns to the tour. const bool tour_on_screen = state.tour && screen_ != Screen::Learn; View view = screen_ == Screen::Main ? View::Main : screen_ == Screen::Settings ? View::Settings : screen_ == Screen::Reset ? View::Reset : screen_ == Screen::Report ? View::Report : screen_ == Screen::Pick ? View::Pick : View::Learn; std::optional tour_step; if (calibration) { using Phase = CalibrationView::Phase; view = calibration->phase == Phase::Recording ? View::Record : View::Try; if (view == View::Record) record_screen(state, *calibration); else try_screen(state, *calibration); } else if (tour_on_screen) { view = View::Tour; tour_step = state.tour->step; tour_screen(state, *state.tour); if (screen_ == Screen::SkipTour) confirm_card("Skip the tour?", "You can replay it any time from Settings.", "Keep going", Screen::Main, "Skip", PanelClick{PanelCommand::TourSkip}, false); } else if (screen_ == Screen::Main) { main_screen(state); } else if (screen_ == Screen::Settings) { settings_screen(state); } else if (screen_ == Screen::Reset) { settings_screen(state); confirm_card("Reset KnockThru?", "This deletes your settings, blocked games, and calibration. The tour starts again afterward.", "Cancel", Screen::Settings, "Reset", PanelClick{PanelCommand::FactoryReset}, true); } else if (screen_ == Screen::Report) { settings_screen(state); report_card(state); } else if (screen_ == Screen::Pick) { pick_screen(state); } else { learn_screen(state); } if (state.update_notice && !calibration && !tour_on_screen) update_card(*state.update_notice); // A new view rises into place. The first render has nothing to move from. // The reset confirm appears over Settings in place, so only the other views rise. const auto over_settings = [](std::optional v) { return v == View::Reset || v == View::Report; }; const bool overlay = (over_settings(view) && view_ == View::Settings) || (view == View::Settings && over_settings(view_)); if ((view_ && *view_ != view && !overlay) || (tour_step && tour_step_ && *tour_step_ != *tour_step)) view_t_ = now; view_ = view; tour_step_ = tour_step; const float entering = static_cast((now - view_t_) / 0.3); if (entering < 1 && now > 0) { scratch_ = canvas_; canvas_ = background_; canvas_.draw(scratch_, 0, static_cast(std::lround(20 * (1 - anim::ease_out(entering)))), anim::ease_out(entering)); } animating_ = view == View::Pick || view == View::Learn || view == View::Record || view == View::Try || view == View::Tour || entering < 1; return canvas_; } std::optional Panel::click(float x, float y) { for (const auto& target : targets_) { if (!target.rect.contains(x, y)) continue; if (target.go_to) { // Leaving the calibration screens hides the Saved banner. if (screen_ == Screen::Pick && *target.go_to == Screen::Settings) saved_dismissed_ = true; screen_ = *target.go_to; if (target.learn) learn_kind_ = *target.learn; } return target.click ? target.click : PanelClick{PanelCommand::Navigate}; } return std::nullopt; } void Panel::draw_icon(Canvas& canvas, IconStyle style) { const bool tile = style == IconStyle::Tile; const float s = static_cast(canvas.width()); canvas.clear({0, 0, 0, 0}); if (tile) canvas.rounded_rect({0, 0, s, s}, s * 0.22f, {kBackground.r, kBackground.g, kBackground.b}); // Two Steam Frame controllers knocking heads, from logo_shapes.hpp on a 64 by 64 grid. The // mark spans x 7.3 to 56.7 and y 8.1 to 56.2. Without the tile, it fills the canvas. const float k = tile ? s / 64 : s / 50; const float shift = tile ? 0 : 7; // The bar version has wider gaps, so they stay visible at the bar's 38 px. const int* ring_sizes = tile ? logo::kRingSizes : logo::bar::kRingSizes; const float* point = tile ? logo::kPoints : logo::bar::kPoints; static_assert(std::size(logo::kShapes) == std::size(logo::bar::kShapes)); for (std::size_t n = 0; n < std::size(logo::kShapes); ++n) { const auto& shape = tile ? logo::kShapes[n] : logo::bar::kShapes[n]; std::vector> rings; for (int r = shape.first_ring; r < shape.first_ring + shape.ring_count; ++r) { auto& ring = rings.emplace_back(); for (int i = 0; i < ring_sizes[r]; ++i, point += 2) ring.push_back({(point[0] - shift) * k, (point[1] - shift) * k}); } // The white bar icon takes Steam's tint: grey when idle, and white on hover. Its accent // parts are half transparent, so they stay apart from the controllers like the blue does. const bool accent = shape.fill == logo::Fill::Accent; canvas.polygon(rings, style == IconStyle::BarWhite ? Color{255, 255, 255, static_cast(accent ? 128 : 255)} : accent ? kAccent : kText); } } } // namespace knockthru