Tighten review panel; add battery and dashboard indicators; Type sends Enter when idle

- Drop the fixed 'Type adds a space' hint; a Bindings failure note now
  replaces the page count instead.
- Grow the transcript card to six lines, closing the empty band above the
  footer; move pagination down.
- Header: right-aligned clock/date, L/HMD/R battery readouts from OpenVR
  battery properties (headset falls back to the Linux system battery);
  missing readings are hidden.
- Status line chip shows Buttons ready/paused from IsDashboardVisible().
- Type (A or button) with nothing to review queues Enter alone; the button
  is labelled Enter in that state.

Offline and native x86-64 builds pass; not yet deployed to Frame.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
baketnkandClaude Opus 5.5 committed 2026-09-25 10:47:11 -04:00
1 parent 59f4f9cff8
commit 4c13b045e2
9 files changed
+179 -35

No files matched your search

+1 -1
View File
@@ -27,7 +27,7 @@ sh install.sh --mode binary --archive /path/to/frameyap-VERSION-linux-aarch64.ta
| --- | --- | | --- | --- |
| Right X, hold / release | Record while held; release to transcribe. | | Right X, hold / release | Record while held; release to transcribe. |
| Right B | Cancel/discard, or close the Quick phrases picker. | | Right B | Cancel/discard, or close the Quick phrases picker. |
| Right A | **Type:** queue reviewed text, normally with a trailing space. | | Right A | **Type:** queue reviewed text, normally with a trailing space. With nothing to review, a press queues Enter alone (so a quick double press types then submits). |
| Right Y | Open **Quick phrases**; press again to cycle the selection. | | Right Y | Open **Quick phrases**; press again to cycle the selection. |
| Left grip, double-tap | **Type + Enter:** queue the selected phrase verbatim + Enter, pending review (normally + space) then Enter, or Enter alone if neither exists. | | Left grip, double-tap | **Type + Enter:** queue the selected phrase verbatim + Enter, pending review (normally + space) then Enter, or Enter alone if neither exists. |
| Overlay Record / Stop | Click-to-start/stop alternative to the PTT binding. | | Overlay Record / Stop | Click-to-start/stop alternative to the PTT binding. |
+23 -7
View File
@@ -54,19 +54,33 @@ GPU setup/submission errors stop startup or the run with an explicit error.
This replaces the raw-upload rendering path; headset flicker acceptance still This replaces the raw-upload rendering path; headset flicker acceptance still
requires an on-device comparison. Native installation and offscreen GPU checks are separate from headset acceptance. requires an on-device comparison. Native installation and offscreen GPU checks are separate from headset acceptance.
The header shows local time and date instead of the former on-device/review The header shows local time and date (right-aligned) instead of the former
and current-mount labels. Settings explains Hold Quit (hold 0.9 seconds then on-device/review and current-mount labels. Between the title and the clock it
release) and Lasers anytime (system-wide lasers may affect games). The review shows battery levels for the left controller, headset (HMD) and right controller.
tab describes Type and Type + Enter. It updates when the displayed minute or date changes, Controllers and the headset use OpenVR's `Prop_DeviceBatteryPercentage_Float`
(with `Prop_DeviceIsCharging_Bool`) when the device reports
`Prop_DeviceProvidesBatteryStatus_Bool`; if the headset reports none, the local
Linux `/sys/class/power_supply` system battery is read instead (peripheral
`scope=Device` supplies are skipped). Levels refresh every five seconds; a device
without a reading is hidden, never shown as zero. 20% or less is drawn in the
warning color, charging in the accent color with `+`. The status line carries a
**Buttons ready / Buttons paused** chip from `IVROverlay::IsDashboardVisible()`:
while the SteamVR dashboard is open, controller bindings do not reach FrameYap,
though pointer clicks still work. The chip reflects dashboard visibility only,
not every system or game input override. `--check-controls` prints the same
battery readings. Which devices Frame actually reports still needs a headset check.
Settings explains Hold Quit (hold 0.9 seconds then
release) and Lasers anytime (system-wide lasers may affect games). The header
updates when the displayed minute, date, battery or dashboard state changes,
not every frame. Settings toggles 12/24-hour time and cycles date Off → not every frame. Settings toggles 12/24-hour time and cycles date Off →
MM/DD/YYYY → DD/MM/YYYY → YYYY-MM-DD → Off. These only affect display; MM/DD/YYYY → DD/MM/YYYY → YYYY-MM-DD → Off. These only affect display;
mount choices remain in Settings. mount choices remain in Settings.
The complete transcript preview is paginated by glyph width and four-line The complete transcript preview is paginated by glyph width and six-line
height; Previous and Next navigate it without changing the source transcript. height; Previous and Next navigate it without changing the source transcript.
Status fits on the single status line; the old bottom detail label is gone. Status fits on the single status line; the old bottom detail label is gone.
The footer remains available on all tabs: Record (labelled Stop while recording), The footer remains available on all tabs: Record (labelled Stop while recording),
Cancel, Type, Type + Enter, Hold Quit. Hold Quit needs a 900 ms press and release on Cancel, Type (labelled Enter when nothing is pending review), Type + Enter, Hold Quit. Hold Quit needs a 900 ms press and release on
that same button; its thin progress bar shows the hold. Record can retry after an that same button; its thin progress bar shows the hold. Record can retry after an
error; it is disabled while warming/transcribing and until an existing review is error; it is disabled while warming/transcribing and until an existing review is
typed or discarded. Cancel can stop worker startup. Type and Type + Enter are typed or discarded. Cancel can stop worker startup. Type and Type + Enter are
@@ -76,7 +90,9 @@ tab changes, action-state changes and relocation clear pending presses. Type + E
is *always* a separate deliberate action, not inferred from text. Type normally is *always* a separate deliberate action, not inferred from text. Type normally
appends a trailing space (without doubling an existing one); a full 4096-byte appends a trailing space (without doubling an existing one); a full 4096-byte
transcript without room for that suffix is queued unchanged, with no extra error transcript without room for that suffix is queued unchanged, with no extra error
for the missing space. Type + Enter types any pending review and then queues for the missing space. With no pending review, Type (button or A) queues Enter
alone, the same as Type + Enter; a quick second A after Type therefore submits.
Type + Enter types any pending review and then queues
Enter; with no pending text it queues Enter only. Y opens the Quick phrases Enter; with no pending text it queues Enter only. Y opens the Quick phrases
list over the review area; each further Y press cycles its highlighted choice. Cancel closes the picker without discarding an list over the review area; each further Y press cycles its highlighted choice. Cancel closes the picker without discarding an
existing review. Type + Enter sends the selected phrase *without* a trailing existing review. Type + Enter sends the selected phrase *without* a trailing
+4
View File
@@ -335,6 +335,10 @@ void Controller::action(UiAction action) {
if (text.back() != ' ' && text.size() < 4096) text += ' '; if (text.back() != ' ' && text.size() < 4096) text += ' ';
queue_paced(std::move(text), false, true, manual_focus()); queue_paced(std::move(text), false, true, manual_focus());
} else delivery_detail(deliver_insert(session_, delivery_), false); } else delivery_detail(deliver_insert(session_, delivery_), false);
} else if (!quick_open_ && (session_.state() == State::Ready || session_.state() == State::Queued)) {
// A deliberate Type press with nothing to review is an explicit Enter.
if (paced_) queue_paced({}, true, false, manual_focus());
else delivery_detail(deliver_enter(session_, delivery_), true);
} }
break; break;
case UiAction::Enter: case UiAction::Enter:
+1 -1
View File
@@ -37,7 +37,7 @@ void help() {
"SteamVR Developer setting Enable global input from overlays.\n" "SteamVR Developer setting Enable global input from overlays.\n"
"Right X: hold to speak, release to review (default Frame binding).\n" "Right X: hold to speak, release to review (default Frame binding).\n"
"Grip gestures are remappable but may be unavailable in the dashboard.\n" "Grip gestures are remappable but may be unavailable in the dashboard.\n"
"Right B: cancel; A: Type (text + space); Y: Quick phrases picker.\n" "Right B: cancel; A: Type (text + space, or Enter alone when no review); Y: Quick phrases.\n"
"Left grip: double-tap to Type + Enter (Enter alone when no review).\n" "Left grip: double-tap to Type + Enter (Enter alone when no review).\n"
"Review by default. Auto insert requires uninterrupted verified Xwayland focus.\n" "Review by default. Auto insert requires uninterrupted verified Xwayland focus.\n"
"Type approves current focus; Enter is never automatic.\n" "Type approves current focus; Enter is never automatic.\n"
+33 -1
View File
@@ -66,6 +66,10 @@ template<class Query> std::vector<std::string> vulkan_extensions(Query query) {
for (std::string name; words >> name;) result.push_back(std::move(name)); for (std::string name; words >> name;) result.push_back(std::move(name));
return result; return result;
} }
std::string battery_text(const std::optional<BatteryLevel>& level) {
if (!level) return "n/a";
return std::to_string(level->percent) + "%" + (level->charging ? " charging" : "");
}
} // namespace } // namespace
struct Overlay::Impl { struct Overlay::Impl {
@@ -88,6 +92,8 @@ struct Overlay::Impl {
PanelSurface surface; PanelSurface surface;
Panel panel; Panel panel;
std::vector<ModelAction> model_actions; std::vector<ModelAction> model_actions;
StatusIndicators indicators;
std::chrono::steady_clock::time_point batteries_read{};
bool world_ready = false, placed = false, has_texture = false, shown = false; bool world_ready = false, placed = false, has_texture = false, shown = false;
float published_alpha = -1.f; float published_alpha = -1.f;
float size_scale = 1.f; float size_scale = 1.f;
@@ -371,9 +377,33 @@ struct Overlay::Impl {
} }
} }
bool available(UiAction action) const { return surface.available(action); } bool available(UiAction action) const { return surface.available(action); }
std::optional<BatteryLevel> device_battery(vr::TrackedDeviceIndex_t index) const {
if (index == vr::k_unTrackedDeviceIndexInvalid || !system->IsTrackedDeviceConnected(index)) return {};
vr::ETrackedPropertyError error = vr::TrackedProp_Success;
if (!system->GetBoolTrackedDeviceProperty(index, vr::Prop_DeviceProvidesBatteryStatus_Bool, &error) ||
error != vr::TrackedProp_Success) return {};
const float level = system->GetFloatTrackedDeviceProperty(index, vr::Prop_DeviceBatteryPercentage_Float, &error);
if (error != vr::TrackedProp_Success || !std::isfinite(level) || level < 0.f || level > 1.f) return {};
const bool charging = system->GetBoolTrackedDeviceProperty(index, vr::Prop_DeviceIsCharging_Bool, &error);
return BatteryLevel{int(std::lround(level * 100.f)), error == vr::TrackedProp_Success && charging};
}
void update_indicators() {
indicators.dashboard_open = overlay->IsDashboardVisible();
const auto now = std::chrono::steady_clock::now();
if (now - batteries_read >= std::chrono::seconds(5) || batteries_read == decltype(now){}) {
batteries_read = now;
indicators.left = device_battery(system->GetTrackedDeviceIndexForControllerRole(vr::TrackedControllerRole_LeftHand));
indicators.right = device_battery(system->GetTrackedDeviceIndexForControllerRole(vr::TrackedControllerRole_RightHand));
// A standalone headset may report its battery only through Linux.
indicators.head = device_battery(vr::k_unTrackedDeviceIndex_Hmd);
if (!indicators.head) indicators.head = system_battery();
}
surface.set_indicators(indicators);
}
void draw(const Panel& p) { void draw(const Panel& p) {
panel = p; panel = p;
surface.set_clock_time(std::time(nullptr)); surface.set_clock_time(std::time(nullptr));
update_indicators();
if (surface.render(p, PanelSurface::Clock::now(), shown)) { if (surface.render(p, PanelSurface::Clock::now(), shown)) {
gpu_texture->upload(surface.pixels()); gpu_texture->upload(surface.pixels());
auto texture = gpu_texture->texture(); auto texture = gpu_texture->texture();
@@ -413,7 +443,9 @@ struct Overlay::Impl {
"\nMode dashboard=" + (overlay->IsDashboardVisible() ? "Y" : "N") + "\nMode dashboard=" + (overlay->IsDashboardVisible() ? "Y" : "N") +
" lasers-anytime=" + (laser_error == vr::VROverlayError_None ? (laser_flag ? "Y" : "N") : "n/a") + " lasers-anytime=" + (laser_error == vr::VROverlayError_None ? (laser_flag ? "Y" : "N") : "n/a") +
" system-input-available=" + (system->IsInputAvailable() ? "Y" : "N") + " system-input-available=" + (system->IsInputAvailable() ? "Y" : "N") +
" panel-shown=" + (shown ? "Y" : "N") + " focus-gate=" + (focus ? "Y" : "N"); " panel-shown=" + (shown ? "Y" : "N") + " focus-gate=" + (focus ? "Y" : "N") +
"\nBattery L=" + battery_text(indicators.left) + " HMD=" + battery_text(indicators.head) +
" R=" + battery_text(indicators.right);
} }
// Bound actions are accepted only with a connected tracked source. A held input // Bound actions are accepted only with a connected tracked source. A held input
// following loss of activity must return to neutral before generating an edge. // following loss of activity must return to neutral before generating an edge.
+71 -14
View File
@@ -39,8 +39,8 @@ constexpr std::array<Button, 32> buttons{{
{{226, 138, 180, 46}, Control::Settings, "Settings"}, {{226, 138, 180, 46}, Control::Settings, "Settings"},
{{420, 138, 180, 46}, Control::Bindings, "Bindings"}, {{420, 138, 180, 46}, Control::Bindings, "Bindings"},
{{620, 138, 348, 46}, Control::LockLayout, "Lock grab/scale"}, {{620, 138, 348, 46}, Control::LockLayout, "Lock grab/scale"},
{{32, 406, 154, 44}, Control::Prev, "Previous"}, {{32, 496, 154, 44}, Control::Prev, "Previous"},
{{838, 406, 130, 44}, Control::Next, "Next"}, {{838, 496, 130, 44}, Control::Next, "Next"},
{{32, 574, 176, 68}, Control::Record, "Record"}, {{32, 574, 176, 68}, Control::Record, "Record"},
{{222, 574, 176, 68}, Control::Cancel, "Cancel"}, {{222, 574, 176, 68}, Control::Cancel, "Cancel"},
{{412, 574, 176, 68}, Control::Insert, "Type"}, {{412, 574, 176, 68}, Control::Insert, "Type"},
@@ -127,6 +127,7 @@ struct PanelSurface::Impl {
std::time_t clock_time = std::time(nullptr); std::time_t clock_time = std::time(nullptr);
ClockLabel displayed_clock; ClockLabel displayed_clock;
std::string placement_note, binding_note; std::string placement_note, binding_note;
StatusIndicators indicators;
std::array<int, 2> pressed{{-1, -1}}; std::array<int, 2> pressed{{-1, -1}};
std::array<PanelSurface::Clock::time_point, 2> press_time{}; std::array<PanelSurface::Clock::time_point, 2> press_time{};
int hold_progress = 0; int hold_progress = 0;
@@ -139,7 +140,7 @@ struct PanelSurface::Impl {
bool install_confirm = false; bool install_confirm = false;
std::optional<ModelOption> consent_snapshot; std::optional<ModelOption> consent_snapshot;
std::vector<std::string> consent_lines; std::vector<std::string> consent_lines;
static constexpr size_t lines_per_page = 4; static constexpr size_t lines_per_page = 6;
static constexpr size_t consent_lines_per_page = 6; static constexpr size_t consent_lines_per_page = 6;
size_t consent_pages() const { size_t consent_pages() const {
return std::max(size_t(1), (consent_lines.size() + consent_lines_per_page - 1) / consent_lines_per_page); return std::max(size_t(1), (consent_lines.size() + consent_lines_per_page - 1) / consent_lines_per_page);
@@ -221,6 +222,30 @@ struct PanelSurface::Impl {
std::clamp(.5f - d, 0.f, 1.f)); std::clamp(.5f - d, 0.f, 1.f));
} }
} }
// Compact "L [###] 82%" groups; missing devices are omitted, not zeroed.
void batteries(int x, int right) {
const std::array<std::pair<const char*, const std::optional<BatteryLevel>*>, 3> groups{{
{"L", &indicators.left}, {"HMD", &indicators.head}, {"R", &indicators.right}}};
for (const auto& [name, level] : groups) {
if (!*level) continue;
const auto& b = **level;
const std::string percent = std::to_string(b.percent) + "%";
const int width = measure(name, 18) + 6 + 30 + 6 + measure(percent, 20) + (b.charging ? 12 : 0);
if (x + width > right) return;
text(name, x, 56, 18, muted, right);
x += measure(name, 18) + 6;
const Color tone = b.charging ? cyan : b.percent <= 20 ? pink : ink;
rounded({x, 42, 26, 16}, 4, background, mix(background, tone, .8f));
rect({x + 26, 47, 3, 6}, mix(background, tone, .8f));
const int fill = std::max(b.percent > 0 ? 1 : 0, 20 * std::clamp(b.percent, 0, 100) / 100);
if (fill) rect({x + 3, 45, fill, 10}, tone);
x += 36;
text(percent, x, 57, 20, tone, right);
x += measure(percent, 20);
if (b.charging) { text("+", x + 1, 55, 18, cyan, right); x += 12; }
x += 18;
}
}
void prepare_gradient(Clock::time_point now) { void prepare_gradient(Clock::time_point now) {
// A periodic cosine field has matching values AND velocity at both // A periodic cosine field has matching values AND velocity at both
// spatial and temporal seams. One phase drives the body and handles. // spatial and temporal seams. One phase drives the body and handles.
@@ -268,6 +293,12 @@ struct PanelSurface::Impl {
if (FT_Load_Char(face, cp, FT_LOAD_DEFAULT)) return 0; if (FT_Load_Char(face, cp, FT_LOAD_DEFAULT)) return 0;
return int(face->glyph->advance.x >> 6); return int(face->glyph->advance.x >> 6);
} }
int measure(std::string_view s, unsigned px) {
size(px);
int width = 0;
for (size_t i = 0; i < s.size();) width += advance(next_codepoint(s, i));
return width;
}
std::vector<std::string> wrap(std::string_view s, unsigned px, int width) { std::vector<std::string> wrap(std::string_view s, unsigned px, int width) {
size(px); size(px);
std::vector<std::string> result; std::vector<std::string> result;
@@ -314,7 +345,8 @@ struct PanelSurface::Impl {
size_t page_count() const { return std::max(size_t(1), (lines.size() + lines_per_page - 1) / lines_per_page); } size_t page_count() const { return std::max(size_t(1), (lines.size() + lines_per_page - 1) / lines_per_page); }
bool available(UiAction a) const { bool available(UiAction a) const {
if (a == UiAction::Record) return tab != Tab::Models && !panel.quick_open && (panel.recording || panel.record_available); if (a == UiAction::Record) return tab != Tab::Models && !panel.quick_open && (panel.recording || panel.record_available);
if (a == UiAction::Insert) return tab != Tab::Models && !panel.quick_open && panel.enabled && !panel.recording && !panel.transcript.empty(); // With nothing to review, Type is an explicit Enter.
if (a == UiAction::Insert) return tab != Tab::Models && !panel.quick_open && panel.enabled && !panel.recording;
if (a == UiAction::Enter) return tab != Tab::Models && panel.enabled && !panel.recording; if (a == UiAction::Enter) return tab != Tab::Models && panel.enabled && !panel.recording;
if (a == UiAction::QuickChat) return tab != Tab::Models && panel.enabled && !panel.recording && !panel.quick_inputs.empty(); if (a == UiAction::QuickChat) return tab != Tab::Models && panel.enabled && !panel.recording && !panel.quick_inputs.empty();
if (a == UiAction::Toggle) return false; if (a == UiAction::Toggle) return false;
@@ -403,16 +435,37 @@ struct PanelSurface::Impl {
paint_background(); paint_background();
frame(); frame();
text("FrameYap", 32, 61, 40, ink, 300); text("FrameYap", 32, 61, 40, ink, 300);
text(displayed_clock.time, 475, 58, 28, ink, 735); // Clock and date are right-aligned; batteries fill the space after the title.
if (!displayed_clock.date.empty()) text(displayed_clock.date, 756, 58, 24, cyan, 968); int clock_right = 968;
if (!displayed_clock.date.empty()) {
clock_right -= measure(displayed_clock.date, 24);
text(displayed_clock.date, clock_right, 58, 24, cyan, 968);
clock_right -= 22;
}
const int clock_left = clock_right - measure(displayed_clock.time, 28);
text(displayed_clock.time, clock_left, 58, 28, ink, clock_right);
batteries(222, clock_left - 20);
rounded({32, 78, 936, 48}, 13, mix(background, card, .7f), rounded({32, 78, 936, 48}, 13, mix(background, card, .7f),
panel.recording ? mix(card, pink, .36f) : mix(card, cyan, .22f), panel.recording ? mix(card, pink, .36f) : mix(card, cyan, .22f),
panel.recording ? .16f : 0.f); panel.recording ? .16f : 0.f);
rounded({46, 95, 13, 13}, 6, panel.recording ? pink : cyan, rounded({46, 95, 13, 13}, 6, panel.recording ? pink : cyan,
panel.recording ? pink : cyan, panel.recording ? .40f : .20f); panel.recording ? pink : cyan, panel.recording ? .40f : .20f);
auto status = wrap(panel.status, 27, 790); int status_right = 968;
text(status.front(), 70, 109, 27, ink, 844); if (indicators.dashboard_open) {
if (status.size() > 1) text("[...]", 864, 109, 23, muted, 968); // The SteamVR dashboard owns controller buttons while it is open;
// the pointer still works. Mirrors what the wearer can use right now.
const bool paused = *indicators.dashboard_open;
const char* label = paused ? "Buttons paused" : "Buttons ready";
const Rect chip{958 - measure(label, 19) - 38, 87, measure(label, 19) + 38, 30};
rounded(chip, 12, mix(card, paused ? pink : cyan, .12f), paused ? pink : mix(card, cyan, .5f),
0.f, 1);
rounded({chip.x + 11, 97, 10, 10}, 5, paused ? pink : cyan, paused ? pink : cyan);
text(label, chip.x + 28, 108, 19, paused ? pink : ink, chip.x + chip.w - 4);
status_right = chip.x - 12;
}
auto status = wrap(panel.status, 27, status_right - 70 - 74);
text(status.front(), 70, 109, 27, ink, status_right - 70);
if (status.size() > 1) text("[...]", status_right - 66, 109, 23, muted, status_right);
if (tab == Tab::Review && panel.quick_open) { if (tab == Tab::Review && panel.quick_open) {
rounded({32, 212, 936, 324}, 16, mix(card, muted, .08f), mix(card, cyan, .25f), .18f); rounded({32, 212, 936, 324}, 16, mix(card, muted, .08f), mix(card, cyan, .25f), .18f);
text("QUICK PHRASES Y: NEXT TYPE + ENTER: CHOICE + ENTER CANCEL: CLOSE", 48, 243, 21, cyan, 952); text("QUICK PHRASES Y: NEXT TYPE + ENTER: CHOICE + ENTER CANCEL: CLOSE", 48, 243, 21, cyan, 952);
@@ -424,13 +477,13 @@ struct PanelSurface::Impl {
text(panel.quick_inputs[i], 66, row.y + 28, 26, selected ? ink : muted, 930); text(panel.quick_inputs[i], 66, row.y + 28, 26, selected ? ink : muted, 930);
} }
} else if (tab == Tab::Review) { } else if (tab == Tab::Review) {
rounded({32, 212, 936, 178}, 16, mix(card, muted, .08f), mix(card, cyan, .25f), .18f); rounded({32, 204, 936, 278}, 16, mix(card, muted, .08f), mix(card, cyan, .25f), .18f);
for (size_t i = 0; i < lines_per_page && page * lines_per_page + i < lines.size(); ++i) for (size_t i = 0; i < lines_per_page && page * lines_per_page + i < lines.size(); ++i)
text(lines[page * lines_per_page + i], 48, 246 + int(i) * 40, 32, text(lines[page * lines_per_page + i], 48, 240 + int(i) * 42, 32,
panel.transcript.empty() ? muted : ink, 952); panel.transcript.empty() ? muted : ink, 952);
text("PAGE " + std::to_string(page + 1) + " / " + std::to_string(page_count()), 416, 436, 23, muted, 790); // A Bindings failure is the only note here; it replaces the page count.
text(binding_note.empty() ? "Type adds a space; Type + Enter explicitly submits." : binding_note, if (!binding_note.empty()) text(binding_note, 200, 526, 18, pink, 824);
32, 203, 20, muted, 968); else text("PAGE " + std::to_string(page + 1) + " / " + std::to_string(page_count()), 416, 526, 23, muted, 790);
} else if (tab == Tab::Models) { } else if (tab == Tab::Models) {
text("Models: select to restart. Install always needs explicit confirmation.", 32, 202, 19, muted, 968); text("Models: select to restart. Install always needs explicit confirmation.", 32, 202, 19, muted, 968);
const auto it = std::find_if(panel.models.begin(), panel.models.end(), [&](const auto& m) { return m.id == panel.selected_backend; }); const auto it = std::find_if(panel.models.begin(), panel.models.end(), [&](const auto& m) { return m.id == panel.selected_backend; });
@@ -489,6 +542,7 @@ struct PanelSurface::Impl {
(m.id == panel.selected_backend && panel.model_busy ? "checking" : m.state); (m.id == panel.selected_backend && panel.model_busy ? "checking" : m.state);
}() : b.id == Control::ModelInstall && install_confirm ? "Confirm Install" : }() : b.id == Control::ModelInstall && install_confirm ? "Confirm Install" :
b.id == Control::Record && panel.recording ? "Stop" : b.id == Control::Record && panel.recording ? "Stop" :
b.id == Control::Insert && panel.transcript.empty() ? "Enter" :
b.id == Control::Clock24h ? (clock_24h ? "Clock: 24 hour" : "Clock: 12 hour") : b.id == Control::Clock24h ? (clock_24h ? "Clock: 24 hour" : "Clock: 12 hour") :
b.id == Control::Date ? (date_format == DateFormat::Off ? "Date: Off" : b.id == Control::Date ? (date_format == DateFormat::Off ? "Date: Off" :
date_format == DateFormat::MonthDayYear ? "Date: MM/DD/YYYY" : date_format == DateFormat::MonthDayYear ? "Date: MM/DD/YYYY" :
@@ -629,6 +683,9 @@ SurfaceEvent PanelSurface::pointer_up(unsigned cursor, float x, float y, Clock::
else if (c == Control::Next) { ++impl_->page; impl_->dirty = true; } else if (c == Control::Next) { ++impl_->page; impl_->dirty = true; }
return result; return result;
} }
void PanelSurface::set_indicators(const StatusIndicators& indicators) {
if (impl_->indicators != indicators) { impl_->indicators = indicators; impl_->dirty = true; }
}
void PanelSurface::set_binding_note(std::string note) { void PanelSurface::set_binding_note(std::string note) {
if (impl_->binding_note != note) { impl_->binding_note = std::move(note); impl_->dirty = true; } if (impl_->binding_note != note) { impl_->binding_note = std::move(note); impl_->dirty = true; }
} }
+8
View File
@@ -1,5 +1,6 @@
#pragma once #pragma once
#include "overlay.hpp" #include "overlay.hpp"
#include "battery.hpp"
#include "config.hpp" #include "config.hpp"
#include "panel_drag.hpp" #include "panel_drag.hpp"
#include <ctime> #include <ctime>
@@ -24,6 +25,12 @@ struct SurfaceEvent {
bool open_bindings = false; bool open_bindings = false;
std::optional<ModelAction> model_action; std::optional<ModelAction> model_action;
}; };
// Header indicators. Absent values are hidden, never shown as zero or as a guess.
struct StatusIndicators {
std::optional<bool> dashboard_open; // SteamVR dashboard: controller bindings paused
std::optional<BatteryLevel> left, head, right;
bool operator==(const StatusIndicators&) const = default;
};
// One CPU RGBA canvas, independent of OpenVR. Settings replace the review area; // One CPU RGBA canvas, independent of OpenVR. Settings replace the review area;
// status and safety controls remain on the same surface. // status and safety controls remain on the same surface.
class PanelSurface { class PanelSurface {
@@ -60,6 +67,7 @@ public:
void set_date_format(DateFormat format); void set_date_format(DateFormat format);
void set_clock_time(std::time_t now); void set_clock_time(std::time_t now);
void set_binding_note(std::string note); void set_binding_note(std::string note);
void set_indicators(const StatusIndicators& indicators);
bool available(UiAction action) const; bool available(UiAction action) const;
// Exact text rows currently shown in the consent viewport (empty outside it). // Exact text rows currently shown in the consent viewport (empty outside it).
// OpenVR has no screen-reader accessibility channel for this canvas. // OpenVR has no screen-reader accessibility channel for this canvas.
+10 -5
View File
@@ -120,7 +120,8 @@ int main() {
int submitted = f.worker.submissions; int submitted = f.worker.submissions;
c.action(UiAction::EndRecord); // old PTT release cannot submit after restart c.action(UiAction::EndRecord); // old PTT release cannot submit after restart
c.tick(); assert(c.state() == State::Ready && f.worker.submissions == submitted); c.tick(); assert(c.state() == State::Ready && f.worker.submissions == submitted);
c.action(UiAction::Insert); assert(f.input.text == 0); c.action(UiAction::Insert); // nothing to review: Type is an explicit Enter
assert(f.input.text == 0 && f.input.enter == 1);
release(c, f); release(c, f);
f.focus.valid = false; result(c, f, "review to discard"); f.focus.valid = false; result(c, f, "review to discard");
assert(c.state() == State::Review && f.input.text == 0); assert(c.state() == State::Review && f.input.text == 0);
@@ -130,7 +131,7 @@ int main() {
c.action(UiAction::Cancel); // a stale Cancel cannot re-enable an unavailable model c.action(UiAction::Cancel); // a stale Cancel cannot re-enable an unavailable model
c.tick(); c.action(UiAction::BeginRecord); c.action(UiAction::Enter); c.tick(); c.action(UiAction::BeginRecord); c.action(UiAction::Enter);
assert(c.state() == State::Error && f.worker.starts == starts && assert(c.state() == State::Error && f.worker.starts == starts &&
f.input.enter == 0 && f.input.text == 0); f.input.enter == 1 && f.input.text == 0);
c.shutdown(); c.shutdown();
} }
{ {
@@ -167,7 +168,8 @@ int main() {
assert(f.input.text == 0 && f.input.enter == 0); assert(f.input.text == 0 && f.input.enter == 0);
c.action(UiAction::Insert); c.action(UiAction::Insert);
assert(f.input.text == 1 && f.input.sent == "hello " && f.input.enter == 0); assert(f.input.text == 1 && f.input.sent == "hello " && f.input.enter == 0);
c.action(UiAction::Insert); assert(f.input.text == 1); // at most once c.action(UiAction::Insert); // at most once; a second press is only an Enter
assert(f.input.text == 1 && f.input.enter == 1);
release(c, f); result(c, f, std::string(4096, 'x')); release(c, f); result(c, f, std::string(4096, 'x'));
c.action(UiAction::Insert); c.action(UiAction::Insert);
assert(f.input.sent.size() == 4096 && f.input.sent.back() == 'x'); assert(f.input.sent.size() == 4096 && f.input.sent.back() == 'x');
@@ -301,10 +303,13 @@ int main() {
std::string joined; std::string joined;
for (int i = 0; i < 4; ++i) joined += f.input.events[i]; for (int i = 0; i < 4; ++i) joined += f.input.events[i];
assert(joined == std::string(81, 'x') + " " && f.input.events.back() == "<Enter>"); assert(joined == std::string(81, 'x') + " " && f.input.events.back() == "<Enter>");
// With nothing left to review, Type is an explicit Enter; it still obeys the gap.
c.action(UiAction::Insert); assert(f.input.events.size() == 5); c.action(UiAction::Insert); assert(f.input.events.size() == 5);
// A separate Enter press is a new action but still obeys the gap. c.action(UiAction::Enter); // ignored while the Type-Enter is pending
c.action(UiAction::Enter); c.tick(); assert(f.input.enter == 1); c.tick(); assert(f.input.enter == 1);
now += std::chrono::milliseconds(150); c.tick(); assert(f.input.enter == 2); now += std::chrono::milliseconds(150); c.tick(); assert(f.input.enter == 2);
c.action(UiAction::Enter); now += std::chrono::milliseconds(150); c.tick();
assert(f.input.enter == 3 && f.input.text == 4);
c.shutdown(); c.shutdown();
} }
{ {
+28 -6
View File
@@ -57,6 +57,28 @@ int main(int argc, char** argv) {
no_action(click(surface, 207, 575)); no_action(click(surface, 207, 575));
assert(click(surface, 100, 610).action == UiAction::BeginRecord); assert(click(surface, 100, 610).action == UiAction::BeginRecord);
assert(!surface.render(p)); // a diagnostic click does not change the canvas assert(!surface.render(p)); // a diagnostic click does not change the canvas
{
// With nothing to review, the enabled Type button is an explicit Enter.
PanelSurface idle(argv[1], Mount::World, {}, {.enabled = false});
Panel ready{"Ready", "", "", true, false};
idle.render(ready);
assert(idle.available(UiAction::Insert));
assert(click(idle, 480, 610).action == UiAction::Insert);
ready.recording = true; idle.render(ready);
assert(!idle.available(UiAction::Insert));
// Indicators repaint only on change; absent values stay hidden.
ready.recording = false; idle.render(ready);
const auto plain = idle.pixels();
StatusIndicators shown{true, BatteryLevel{82, false}, BatteryLevel{15, false}, BatteryLevel{100, true}};
idle.set_indicators(shown);
assert(idle.render(ready) && idle.pixels() != plain);
idle.set_indicators(shown); assert(!idle.render(ready));
if (argc >= 3) snapshot(idle, std::string(argv[2]) + "-indicators.ppm");
shown.dashboard_open = false; idle.set_indicators(shown);
assert(idle.render(ready));
if (argc >= 3) snapshot(idle, std::string(argv[2]) + "-indicators-closed.ppm");
idle.set_indicators({}); assert(idle.render(ready) && idle.pixels() == plain);
}
assert(click(surface, 280, 610).action == UiAction::Cancel); assert(click(surface, 280, 610).action == UiAction::Cancel);
assert(!surface.render(p)); assert(!surface.render(p));
no_action(surface.pointer_up(0, 100, 610)); no_action(surface.pointer_up(0, 100, 610));
@@ -204,7 +226,7 @@ int main(int argc, char** argv) {
p.quick_open = true; p.quick_open = true;
assert(surface.render(p)); assert(surface.render(p));
const auto first_quick = surface.pixels(); const auto first_quick = surface.pixels();
no_action(click(surface, 900, 430)); // review pagination hidden behind picker no_action(click(surface, 900, 518)); // review pagination hidden behind picker
p.quick_selected = 1; p.quick_selected = 1;
assert(surface.render(p) && surface.pixels() != first_quick); assert(surface.render(p) && surface.pixels() != first_quick);
assert(click(surface, 680, 610).action == UiAction::Enter); assert(click(surface, 680, 610).action == UiAction::Enter);
@@ -351,21 +373,21 @@ int main(int argc, char** argv) {
surface.render(p); surface.render(p);
surface.reset_pointers(); surface.render(p); surface.reset_pointers(); surface.render(p);
const auto first = surface.pixels(); const auto first = surface.pixels();
no_action(click(surface, 900, 430)); no_action(click(surface, 900, 518));
surface.reset_pointers(); assert(surface.render(p)); surface.reset_pointers(); assert(surface.render(p));
assert(surface.pixels() != first); assert(surface.pixels() != first);
no_action(click(surface, 90, 430)); no_action(click(surface, 90, 518));
surface.reset_pointers(); surface.render(p); surface.reset_pointers(); surface.render(p);
assert(surface.pixels() == first); assert(surface.pixels() == first);
for (int i = 0; i < 200; ++i) { no_action(click(surface, 900, 430)); surface.render(p); } for (int i = 0; i < 200; ++i) { no_action(click(surface, 900, 518)); surface.render(p); }
for (int i = 0; i < 200; ++i) { no_action(click(surface, 90, 430)); surface.render(p); } for (int i = 0; i < 200; ++i) { no_action(click(surface, 90, 518)); surface.render(p); }
surface.reset_pointers(); surface.render(p); surface.reset_pointers(); surface.render(p);
assert(surface.pixels() == first); assert(surface.pixels() == first);
p.transcript = "New result"; p.transcript = "New result";
assert(surface.render(p)); assert(surface.render(p));
surface.reset_pointers(); surface.render(p); surface.reset_pointers(); surface.render(p);
auto replaced = surface.pixels(); auto replaced = surface.pixels();
no_action(click(surface, 900, 430)); no_action(click(surface, 900, 518));
surface.reset_pointers(); surface.render(p); surface.reset_pointers(); surface.render(p);
assert(surface.pixels() == replaced); assert(surface.pixels() == replaced);
p.status = std::string(4096, 's'); p.detail = std::string(4096, 'd'); p.status = std::string(4096, 's'); p.detail = std::string(4096, 'd');