mirror of
https://github.com/VioletTides/KnockThru.git
synced 2026-10-17 15:01:36 +02:00
965 lines
53 KiB
C++
965 lines
53 KiB
C++
#include "panel.hpp"
|
|
|
|
#include "animation.hpp"
|
|
#include "calibrator.hpp"
|
|
#include "logo_shapes.hpp"
|
|
#include "sprite_shapes.hpp"
|
|
|
|
#include <qrcodegen.hpp>
|
|
|
|
#include <algorithm>
|
|
#include <array>
|
|
#include <cmath>
|
|
|
|
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 surface, like your leg.",
|
|
"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::uint8_t>(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<std::vector<float>>& source) {
|
|
std::vector<std::vector<Point>> 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<float> 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);
|
|
}
|
|
|
|
// Drawing the icon from its outlines is slow, so each size and style is drawn once and kept.
|
|
const Canvas& Panel::icon(int size, IconStyle style) {
|
|
const auto key = std::make_pair(size, style);
|
|
auto found = icons_.find(key);
|
|
if (found == icons_.end()) {
|
|
found = icons_.emplace(key, Canvas(size, size)).first;
|
|
draw_icon(found->second, style);
|
|
}
|
|
return found->second;
|
|
}
|
|
|
|
// Draws `text` wrapped to `width` in at most `max_lines` lines, `leading` pixels apart.
|
|
// Returns the y after the last line.
|
|
float Panel::paragraph(Font& font, const std::string& text, float x, float y, float size, float leading, float width,
|
|
int max_lines, Color color) {
|
|
for (const auto& line : canvas_.wrap(font, text, size, width, max_lines)) {
|
|
canvas_.text(font, line, x, y, size, color);
|
|
y += leading;
|
|
}
|
|
return y;
|
|
}
|
|
|
|
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<PanelClick> click, std::optional<Screen> 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_.draw(icon(56, IconStyle::Tile), static_cast<int>(kMargin), 40);
|
|
canvas_.text(bold_, title, kMargin + 76, 68, 38, kText);
|
|
}
|
|
|
|
void Panel::message(const PanelState& state, float y) {
|
|
paragraph(regular_, state.message, kMargin, y, 26, 36, kWidth - 2 * kMargin, 2, state.message_is_error ? kError : kDim);
|
|
}
|
|
|
|
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) {
|
|
paragraph(regular_, state.room_view_error, x, rect.y + rect.h - 92, 26, 36, w, 2, kText);
|
|
}
|
|
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<PanelClick> 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<PanelClick> click, std::optional<Screen> 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<KnockKind>(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<float>(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<int>(*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<KnockKind>(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<KnockKind>(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<int>(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<float>(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.")}) {
|
|
y = paragraph(regular_, line, x, y, 28, 40, w, 2, kDim);
|
|
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<int>(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<float>(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<int>(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<float>(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<int>(view.speeds.size()) ? view.speeds[i] : 0;
|
|
const float h = std::max(8.0f, static_cast<float>(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<float>((now_ - view.started_t) / Calibrator::kGetReady))
|
|
: anim::clamp(static_cast<float>((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<float>(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<int>(*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<int>(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<float>(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<float>(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<float>(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;
|
|
paragraph(regular_, "Heard " + view.trial_heard + ". Try again, or record your knocks again.", status.x + 40, y,
|
|
26, 36, status.w - 80, 3, kDim);
|
|
}
|
|
|
|
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_.draw(icon(64, state.classic_bar_icon ? IconStyle::BarColor : IconStyle::BarWhite), static_cast<int>(kMargin + w - 24 - 80 - 40 - 64), static_cast<int>(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<PanelClick> 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<Point> outline;
|
|
const std::array<std::array<float, 3>, 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<float>(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<const char*, 3>{"Scan this code with your phone.", "Tap Copy report and open GitHub.",
|
|
"Paste, say what went wrong, and tap Create."}
|
|
: std::array<const char*, 3>{"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.";
|
|
paragraph(regular_, hint, x, y, 26, 36, text_w, 3, kDim);
|
|
|
|
// 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;
|
|
paragraph(regular_, record_note, record_x, record.y + record.h / 2 - 16, 24, 32, text_w - record.w - 24, 2, kDim);
|
|
|
|
// 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<int>(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<float>((end - mx) * module), static_cast<float>(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<std::string>{} : canvas_.wrap(regular_, note, 24, note_w, 2);
|
|
float ny = by + 36 - 16 * (static_cast<float>(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);
|
|
paragraph(regular_, text, card.x + 48, card.y + 140, 28, 40, card.w - 96, 3, kText);
|
|
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<float>(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<KnockKind> shown;
|
|
for (int k = 0; k < 3; ++k)
|
|
if (state.enabled[k]) shown.push_back(static_cast<KnockKind>(k));
|
|
if (shown.empty()) shown = {KnockKind::Together, KnockKind::Surface, KnockKind::Headset};
|
|
const double loop = 2.6;
|
|
const auto kind = shown[static_cast<std::size_t>(now_ / loop) % shown.size()];
|
|
knock_scene(kind, {box.x + 20, box.y + 40, box.w - 40, 340}, static_cast<float>(std::fmod(now_, loop)), triple ? 3 : 2);
|
|
canvas_.text(bold_, kKindTitles[static_cast<int>(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;
|
|
const auto title = triple ? "Knock three times to switch between translucent and opaque" : "Knock twice to switch passthrough";
|
|
y = paragraph(bold_, title, x, y, 36, 48, w, 2, kText);
|
|
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 surface like your leg", "a controller on the headset"};
|
|
std::vector<std::string> 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 + ".";
|
|
paragraph(regular_, how, x, y, 28, 40, w, 3, kDim);
|
|
|
|
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);
|
|
paragraph(regular_, state.room_view_error, status.x + 40, status.y + 118, 26, 36, status.w - 80, 2, kDim);
|
|
} else if (heard_t < 0) {
|
|
canvas_.rounded_rect(status, kRadius, kSurface);
|
|
const float pulse = 0.5f + 0.5f * std::sin(static_cast<float>(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<float>(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<int>(tour.step) + 1, 6);
|
|
const float cx = kWidth / 2.0f;
|
|
switch (tour.step) {
|
|
case Step::Welcome: {
|
|
canvas_.draw(icon(152, IconStyle::Tile), (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<KnockKind>(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<float>(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<KnockKind>(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<KnockKind>(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<std::pair<float, float>> 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<TourView::Step> 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<View> 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<float>((now - view_t_) / 0.3);
|
|
if (entering < 1 && now > 0) {
|
|
scratch_ = canvas_;
|
|
canvas_ = background_;
|
|
canvas_.draw(scratch_, 0, static_cast<int>(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<PanelClick> 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<float>(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<std::vector<Point>> 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<std::uint8_t>(accent ? 128 : 255)}
|
|
: accent ? kAccent : kText);
|
|
}
|
|
}
|
|
|
|
} // namespace knockthru
|