mirror of
https://github.com/SteamFramelyHomebrew/framely.git
synced 2026-10-18 01:02:16 +02:00
feat(dock): use click and hold entry gestures with progress ring
This commit is contained in:
1 parent
09320554bb
commit
ce3b460fcb
11 files changed
+142
-23
No files matched your search
@@ -39,7 +39,7 @@ curl -fsSL https://raw.githubusercontent.com/SteamFramelyHomebrew/framely/main/i
|
||||
|
||||
Replace the example tag with the published version you want to install. Enter the sudo password when prompted. See the [installation guide](docs/user-guide/installation.md) for updates, repair, and uninstallation.
|
||||
|
||||
First installation uses the complete offline package with CEF; updates use the smaller core package and reuse CEF; the device needs neither Node.js nor Rust. Installation requests sudo and does not automatically disable SteamOS read-only protection. Press the main trigger on the Framely Dock icon in the SteamVR Dashboard to open the launcher, or the upper trigger to open the quick menu. Open the manager from the launcher’s Settings button. The trigger actions can be swapped in Launcher settings.
|
||||
First installation uses the complete offline package with CEF; updates use the smaller core package and reuse CEF; the device needs neither Node.js nor Rust. Installation requests sudo and does not automatically disable SteamOS read-only protection. Click the Framely Dock icon in the SteamVR Dashboard to open the launcher, or hold it for 600 ms to open the quick panel; a progress ring appears while holding. Open the manager from the launcher’s Settings button.
|
||||
|
||||
The network panel uses port `15915` by default. Visit `http://DEVICE_IP:15915` on your phone or computer. When no password is configured, the first visit requires setting and confirming a password before login. The device password and panel password are independent.
|
||||
|
||||
|
||||
+1
-1
@@ -39,7 +39,7 @@ curl -fsSL https://raw.githubusercontent.com/SteamFramelyHomebrew/framely/main/i
|
||||
|
||||
将示例标签替换为需要安装的已发布版本,并在提示时输入 sudo 密码。更新、修复和卸载步骤见[安装指南](docs/zh-CN/user-guide/installation.md)。
|
||||
|
||||
首次安装使用包含 CEF 的完整离线包,更新使用不含 CEF 的本体包并复用运行库;同时提供独立 CEF 在线下载。设备无需 Node.js 或 Rust。安装会请求 sudo;不会自动解除 SteamOS 只读保护。在 SteamVR Dashboard 中指向 Framely Dock 图标,按主扳机打开启动台,按上扳机打开快捷面板;点击启动台右侧的设置按钮打开管理面板。可在“启动台”设置中互换扳机行为。
|
||||
首次安装使用包含 CEF 的完整离线包,更新使用不含 CEF 的本体包并复用运行库;同时提供独立 CEF 在线下载。设备无需 Node.js 或 Rust。安装会请求 sudo;不会自动解除 SteamOS 只读保护。在 SteamVR Dashboard 中点击 Framely Dock 图标打开启动台,长按 600 毫秒打开快捷面板,按住期间显示进度圈;点击启动台右侧的设置按钮打开管理面板。
|
||||
|
||||
网络面板默认端口 `15915`,手机/电脑访问 `http://设备IP:15915`。未配置密码时首次访问必须设置并确认密码,保存后登录。设备密码与面板密码相互独立。
|
||||
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
[简体中文](zh-CN/launcher.md)
|
||||
|
||||
Point at the Framely Dock icon: by default, use the primary trigger for the launcher and the upper trigger for the quick panel. **Manager → Launcher → Open launcher with the primary trigger** is enabled by default; turning it off swaps these shortcuts immediately. Existing saved trigger preferences are preserved. Other controllers keep the ordinary click entry.
|
||||
Click the Framely Dock icon with any supported click input to open the launcher. Hold it for **600 milliseconds** to open the quick panel; a progress ring shows the hold duration. Releasing after a long press does not also open the launcher. This replaces the primary/upper-trigger split and the trigger-swap setting.
|
||||
|
||||
The launcher is a native Dock dashboard panel, 3.6 meters wide, with the previous 2-meter height retained. Steam places and curves it and switches from the current Dock panel when it opens. It closes when the Dock is hidden. UI drawing is capped at 60 FPS independently of the headset refresh rate to limit pixel readback and texture upload load. This is a drawing limit, not a guaranteed measured frame rate. The launcher uses a 4320 × 2400 texture (3× the logical resolution), retains mipmap filtering for edge quality and uploads only changed regions. App ordering and search results are cached during drag animations. The icon area always reserves space for six columns and three rows, regardless of the number of apps. Each page holds up to 13 icons. Fill the first row with four icons, the second with five, then the third with four. The second row starts half a slot to the left; the third aligns with the first. Partial rows retain those starting positions: nine icons are 4 / 5, ten are 4 / 5 / 1. Collections of up to four icons use one centered row. The Done button uses the same translucent surface, border and highlights as the category selector. The visible gap beside the category selector is wider than the gap between adjacent icon artwork. The icon area is centered independently of the category selector, with a fixed 570-pixel height. All three row positions stay fixed, including when only one or two rows are populated. Button and joystick page changes slide and fade over 300 ms. Category changes slide vertically and fade in over 360 ms, with the category selector’s active surface sliding to the selected item; reduced-motion mode skips these transitions. Switching to another Dock panel replaces the launcher normally. Framely does not change other windows’ transparency or input settings. Closing the launcher returns to Steam’s Dock panel. Steam uses local cover artwork first and falls back to logos or initials. The right capsule opens the plugin library or management settings. Use the left category selector for Favorites, All, Plugins, Steam, Lepton or Desktop apps, the bottom dots to change pages, and the star on hover to favorite apps. Favorited apps show a filled blue star on hover; click it again to remove the favorite without changing the icon order. The transparent surface curves around you; only icons, names and categories are normally visible. Disabled plugins are hidden; safe mode prevents third-party plugin launch. Steam lists installed apps from the Frame's local libraries, not account-only or remote-streaming apps.
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](../launcher.md)
|
||||
|
||||
激光指向 Framely Dock 图标:默认主扳机打开启动台,上扳机打开快捷面板。在 **管理面板 → 启动台 → 主扳机打开启动台** 开关默认开启,关闭后两个入口立即互换。已保存的扳机偏好会继续生效。其他手柄保留普通点击入口。
|
||||
使用任意支持的点击输入点击 Framely Dock 图标,打开启动台。长按 **600 毫秒** 打开快捷面板,按住期间显示进度圈;长按完成后松手不会再打开启动台。此操作替代原来的主扳机、上扳机分工及扳机互换设置。
|
||||
|
||||
启动台是 Dock 内宽 3.6 米、高度保留为 2 米的原生面板,由 Steam 管理位置和曲面;打开时通过 Dock 的面板切换机制替代当前面板,Dock 隐藏时关闭。界面绘制上限固定为 60 FPS,不跟随头显刷新率升高,以限制像素读回与纹理上传负担。这是绘制上限,并不保证实测达到该帧率。启动台使用 4320×2400 的纹理(逻辑分辨率的 3 倍),保留多级纹理过滤以平滑图标边缘,只上传发生变化的区域;拖动动画期间缓存应用排序和搜索结果。图标区域始终保留六列三行的空间,不随应用数量变化;每页最多 13 个图标,按第一排 4 个、第二排 5 个、第三排 4 个的顺序填充。第二排向左错开半格,第三排与第一排对齐;未填满的第二、三排也从相同槽位开始,不按数量重新居中分配。9 个为 4/5,10 个为 4/5/1;1–4 个使用一排居中排列。图标区域独立于分类器居中,高度固定为 570 个界面像素;只有一排或两排图标时,每排的槽位位置仍保持不变。“完成”按钮使用与分类器相同的半透明背景、边框和高光,分类器与图标之间的可见留白大于图标之间的水平留白。按钮与摇杆翻页采用 300 毫秒横向滑动与淡入动效,分类切换采用 360 毫秒纵向滑动与淡入动效,分类器的激活底块同步滑动到选中项,系统启用减少动态效果时跳过这些过渡动画。切换到其他 Dock 面板会正常替代启动台,Framely 不修改其他窗口的透明度或输入设置。关闭启动台后返回 Steam 的 Dock 面板。Steam 优先使用本地封面,缺图时回退到 Logo 或首字母。透明界面沿用户面前弯曲,显示应用图标、名称、分类器和顶部搜索框。右侧胶囊按钮打开管理面板的插件库或设置页。左侧切换收藏、全部、插件、Steam、Lepton 和桌面程序,底部圆点翻页,悬停图标显示收藏按钮,已收藏时显示蓝色实心星标,再次点击可取消收藏。停用插件不显示,安全模式阻止第三方插件启动。Steam 仅展示 Frame 本机各库已安装应用,不包含仅在账号库或串流电脑中的应用。
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
#pragma once
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
#include <vector>
|
||||
|
||||
// Overlay mouse events are authoritative: never synthesize another click from
|
||||
// controller actions. Keep the initiating device/button paired until release.
|
||||
struct EntryHold {
|
||||
using Clock=std::chrono::steady_clock;
|
||||
enum class Action { Idle, Launcher, QuickPanel };
|
||||
static constexpr auto duration=std::chrono::milliseconds(600);
|
||||
bool pressed=false,fired=false;
|
||||
unsigned device=~0u,button=0;
|
||||
Clock::time_point started{},last_inside{};
|
||||
void cancel(){pressed=false;fired=false;}
|
||||
Action tick(Clock::time_point now,bool available,bool inside){
|
||||
if(!pressed)return Action::Idle;
|
||||
if(!available){cancel();return Action::Idle;}
|
||||
if(inside)last_inside=now;
|
||||
// Brief pointer jitter is tolerated, but leaving cannot activate a hold.
|
||||
if(!inside){if(now-last_inside>std::chrono::milliseconds(150))cancel();return Action::Idle;}
|
||||
if(!fired&&now-started>=duration){fired=true;return Action::QuickPanel;}
|
||||
return Action::Idle;
|
||||
}
|
||||
Action event(bool down,unsigned source,unsigned mouse_button,bool available,bool inside,Clock::time_point now){
|
||||
if(down){
|
||||
if(!pressed&&available&&inside){pressed=true;fired=false;device=source;button=mouse_button;started=last_inside=now;}
|
||||
return Action::Idle;
|
||||
}
|
||||
if(!pressed||source!=device||mouse_button!=button)return Action::Idle;
|
||||
auto action=tick(now,available,inside);
|
||||
if(action==Action::Idle&&pressed&&!fired&&available&&inside)action=Action::Launcher;
|
||||
cancel();return action;
|
||||
}
|
||||
float progress(Clock::time_point now)const{
|
||||
return pressed?std::clamp(std::chrono::duration<float>(now-started).count()/.6f,0.f,1.f):0.f;
|
||||
}
|
||||
};
|
||||
|
||||
inline void entry_progress_ring(std::vector<uint8_t>& pixels,float progress){
|
||||
if(progress<=0||pixels.size()!=128*128*4)return;
|
||||
constexpr float pi=3.14159265358979323846f;
|
||||
for(int y=0;y<128;y++)for(int x=0;x<128;x++){
|
||||
float dx=x-63.5f,dy=y-63.5f;
|
||||
float coverage=std::clamp(2.f-std::abs(std::hypot(dx,dy)-58.f),0.f,1.f);
|
||||
if(coverage<=0)continue;
|
||||
float angle=std::atan2(dx,-dy);if(angle<0)angle+=2*pi;
|
||||
bool filled=angle<=progress*2*pi;
|
||||
const int track[3]={66,73,81},blue[3]={147,197,237};
|
||||
int i=(y*128+x)*4;
|
||||
for(int c=0;c<3;c++)pixels[i+c]=uint8_t(pixels[i+c]+((filled?blue[c]:track[c])-pixels[i+c])*coverage);
|
||||
}
|
||||
}
|
||||
+17
-13
@@ -9,6 +9,7 @@
|
||||
#include "dock_uv_geometry.h"
|
||||
#include "menu_geometry.h"
|
||||
#include "menu_input.h"
|
||||
#include "entry_hold.h"
|
||||
#include "launcher_input.h"
|
||||
#include "launcher_fade.h"
|
||||
#include "launcher_priority.h"
|
||||
@@ -75,7 +76,7 @@ static std::string launcher_previous_dashboard;
|
||||
static std::set<std::string> dashboard_keys;
|
||||
static bool launcher_restore_previous=true,launcher_previous_menu=false;
|
||||
static std::chrono::steady_clock::time_point launcher_keyboard_back_until{};
|
||||
static std::atomic<bool> launcher_primary{true};
|
||||
static EntryHold entry_hold;
|
||||
static bool entry_actions_ready=false;
|
||||
static vr::VRActionSetHandle_t entry_set=0,gaze_set=0;
|
||||
static vr::VRActionHandle_t primary_action=0,upper_action=0,aim_action=0,close_action=0,navigate_action=0;
|
||||
@@ -163,8 +164,8 @@ static json poll_http(int port){int fd=socket(AF_INET,SOCK_STREAM,0);if(fd<0)thr
|
||||
// runtime's overlay mouse space, before conversion to CEF's top-left origin.
|
||||
// Keep a wider curved mesh around the narrow button; transparent margins never hit input.
|
||||
static constexpr int dock_canvas_width=640,dock_canvas_height=128,dock_icon_left=256;
|
||||
static std::vector<uint8_t> dock_icon(bool hover,bool active){
|
||||
auto source=icon(hover,active);notification_badge(source,notification_badge_count.load());std::vector<uint8_t> canvas(dock_canvas_width*dock_canvas_height*4,0);
|
||||
static std::vector<uint8_t> dock_icon(bool hover,bool active,float progress=0){
|
||||
auto source=icon(hover,active);entry_progress_ring(source,progress);notification_badge(source,notification_badge_count.load());std::vector<uint8_t> canvas(dock_canvas_width*dock_canvas_height*4,0);
|
||||
for(int row=0;row<dock_canvas_height;row++)std::copy_n(source.data()+row*128*4,128*4,canvas.data()+(row*dock_canvas_width+dock_icon_left)*4);
|
||||
return canvas;
|
||||
}
|
||||
@@ -486,8 +487,8 @@ static void entry_actions(vr::VROverlayHandle_t button,bool available){
|
||||
if(kind==0&&!active)entry_analog[hand].held=false;
|
||||
if(kind==0&&active)gaze_buttons[hand]|=down;
|
||||
bool was_held=entry_buttons[hand][kind].held;
|
||||
if(entry_buttons[hand][kind].update(active,down,hit,now))toggle_entry(kind==0?launcher_primary.load():!launcher_primary.load());
|
||||
else if(!(gaze_allowed&&gaze_active)&&kind==0&&active&&down&&!was_held&&launcher_open&&!launcher_calibrating&&!hit&&!launcher_dock_interaction()&&launcher_dismiss.allows(now,true)&&views.count("launcher")&&!overlays->IsHoverTargetOverlay(views.at("launcher")->overlay))launcher_outside_at=now+std::chrono::milliseconds(60);
|
||||
entry_buttons[hand][kind].update(active,down,hit,now);
|
||||
if(!(gaze_allowed&&gaze_active)&&kind==0&&active&&down&&!was_held&&launcher_open&&!launcher_calibrating&&!hit&&!launcher_dock_interaction()&&launcher_dismiss.allows(now,true)&&views.count("launcher")&&!overlays->IsHoverTargetOverlay(views.at("launcher")->overlay))launcher_outside_at=now+std::chrono::milliseconds(60);
|
||||
}
|
||||
if(launcher_open&&hand==1){vr::InputDigitalActionData_t close{};bool active=input->GetDigitalActionData(close_action,&close,sizeof(close),hands[hand])==vr::VRInputError_None&&close.bActive;bool down=close.bState;
|
||||
if(!active&&device!=vr::k_unTrackedDeviceIndexInvalid&&vr_system->IsTrackedDeviceConnected(device)){char model[256]{};vr_system->GetStringTrackedDeviceProperty(device,vr::Prop_RenderModelName_String,model,sizeof(model));vr::RenderModel_ControllerMode_State_t mode{};vr::RenderModel_ComponentState_t state{};active=vr::VRRenderModels()->GetComponentStateForDevicePath(model,"button_b",hands[hand],&mode,&state);down=active&&(state.uProperties&vr::VRComponentProperty_IsPressed);}
|
||||
@@ -545,9 +546,9 @@ int main(int argc,char** argv){
|
||||
client.api.get_dialog_handler=[](cef_client_t*){return dialog_handler.acquire();};client.api.get_render_handler=[](cef_client_t*){return render_handler.acquire();};client.api.get_life_span_handler=[](cef_client_t*){return life_handler.acquire();};client.api.get_display_handler=[](cef_client_t*){return display_handler.acquire();};client.api.get_request_handler=[](cef_client_t*){return request_handler.acquire();};
|
||||
vr::VROverlayHandle_t button=0;auto err=overlays->CreateOverlay("framely.dock.button","Framely",&button);if(err){cef_shutdown();vr::VR_Shutdown();return 6;}auto pixels=dock_icon(false,false);overlays->SetOverlayRaw(button,pixels.data(),dock_canvas_width,dock_canvas_height,4);overlays->SetOverlayInputMethod(button,vr::VROverlayInputMethod_Mouse);vr::HmdVector2_t scale{{dock_canvas_width,dock_canvas_height}};overlays->SetOverlayMouseScale(button,&scale);vr::VROverlayIntersectionMaskPrimitive_t mask{};mask.m_nPrimitiveType=vr::OverlayIntersectionPrimitiveType_Rectangle;mask.m_Primitive.m_Rectangle={dock_icon_left,0,128,128};if(overlays->SetOverlayIntersectionMask(button,&mask,1)){std::cerr<<"Dock input mask unavailable\n";overlays->DestroyOverlay(button);cef_shutdown();vr::VR_Shutdown();return 6;}overlays->SetOverlayFlag(button,vr::VROverlayFlags_VisibleInDashboard,true);overlays->SetOverlayFlag(button,vr::VROverlayFlags_MakeOverlaysInteractiveIfVisible,true);overlays->SetOverlayFlag(button,vr::VROverlayFlags_SortWithNonSceneOverlays,false);overlays->SetOverlaySortOrder(button,0);overlays->SetOverlayFlag(button,vr::VROverlayFlags_HideLaserIntersection,false);
|
||||
try{create_view("menu","Framely",initial,false,600,840);}catch(const std::exception& e){std::cerr<<e.what()<<"\n";running=0;}
|
||||
std::thread poller([port]{int failures=0;json last_gaze;bool gaze_loaded=false;while(running){try{auto data=poll_http(port);notification_badge_count.store(data.value("notificationBadge",0u));launcher_primary.store(data.value("launcher",json::object()).value("primaryTrigger",true));launcher_prioritize_ui.store(data.value("launcher",json::object()).value("prioritizeUi",true));std::lock_guard<std::mutex> lock(queue_mutex);auto calibration=data.value("gazeCalibration",json(nullptr));if(!gaze_loaded||calibration!=last_gaze){queue.push_back({{"kind","gaze.correction"},{"calibration",calibration}});last_gaze=calibration;gaze_loaded=true;}for(const auto& c:data.at("commands")){if(queue.size()<256)queue.push_back(c);}failures=0;}catch(const std::exception& e){if(++failures>=10){std::cerr<<"Agent connection lost: "<<e.what()<<"\n";running=0;}}std::this_thread::sleep_for(std::chrono::milliseconds(100));}});
|
||||
std::thread poller([port]{int failures=0;json last_gaze;bool gaze_loaded=false;while(running){try{auto data=poll_http(port);notification_badge_count.store(data.value("notificationBadge",0u));launcher_prioritize_ui.store(data.value("launcher",json::object()).value("prioritizeUi",true));std::lock_guard<std::mutex> lock(queue_mutex);auto calibration=data.value("gazeCalibration",json(nullptr));if(!gaze_loaded||calibration!=last_gaze){queue.push_back({{"kind","gaze.correction"},{"calibration",calibration}});last_gaze=calibration;gaze_loaded=true;}for(const auto& c:data.at("commands")){if(queue.size()<256)queue.push_back(c);}failures=0;}catch(const std::exception& e){if(++failures>=10){std::cerr<<"Agent connection lost: "<<e.what()<<"\n";running=0;}}std::this_thread::sleep_for(std::chrono::milliseconds(100));}});
|
||||
std::thread priority_worker([]{LauncherPriority priority;while(running){priority.update(launcher_priority_active.load());std::this_thread::sleep_for(std::chrono::milliseconds(100));}});
|
||||
DockUVState uv{};OverlayPlacement button_placement;bool icon_active=false,icon_hover=false;unsigned icon_badge=0;vr::VROverlayHandle_t last_dock=0;bool was_visible=false;vr::HmdMatrix34_t saved_anchor{},saved_frame{};float saved_size=0;DockCurveEvidence saved_evidence{};auto button_haptic_at=std::chrono::steady_clock::time_point{};auto anchored_at=std::chrono::steady_clock::time_point{};auto sampled_at=std::chrono::steady_clock::time_point{};
|
||||
DockUVState uv{};OverlayPlacement button_placement;bool icon_active=false,icon_hover=false;unsigned icon_badge=0;int icon_progress=0;vr::VROverlayHandle_t last_dock=0;bool was_visible=false;vr::HmdMatrix34_t saved_anchor{},saved_frame{};float saved_size=0;DockCurveEvidence saved_evidence{};auto button_haptic_at=std::chrono::steady_clock::time_point{};auto anchored_at=std::chrono::steady_clock::time_point{};auto sampled_at=std::chrono::steady_clock::time_point{};
|
||||
while(running){auto frame_start=std::chrono::steady_clock::now();cef_do_message_loop_work();commands();vr::VREvent_t global{};while(vr_system->PollNextEvent(&global,sizeof(global))){if(global.eventType==vr::VREvent_Quit){vr_system->AcknowledgeQuit_Exiting();running=0;}observe_dashboard(global);keyboard_event(global);}launcher_priority_active.store(launcher_open&&launcher_prioritize_ui.load());vr::VROverlayHandle_t dock=0;vr::HmdVector2_t mouse_scale{};bool visible=!overlays->FindOverlay("valve.steam.gamepadui.bar",&dock)&&overlays->IsDashboardVisible()&&overlays->IsOverlayVisible(dock)&&!overlays->GetOverlayMouseScale(dock,&mouse_scale);
|
||||
if(visible&&(!was_visible||dock!=last_dock)){uv={};anchored_at={};sampled_at={};}last_dock=dock;
|
||||
// Width and aspect are only ray-solver seeds; physical size comes from intersections.
|
||||
@@ -575,19 +576,22 @@ int main(int argc,char** argv){
|
||||
if(launcher_open||launcher_close_at.time_since_epoch().count())overlays->SetOverlayAlpha(views.at("launcher")->overlay,launcher_open?std::max(.02f,launcher_fade.value(now)):launcher_fade.value(now));
|
||||
if(launcher_open&&!selected&&overlays->IsDashboardVisible()&&!launcher_dock_interaction()&&(launcher_selected||now-launcher_dismiss.opened_at>std::chrono::seconds(1))){if(launcher_calibrating)hide_launcher();else launcher_open=false;}
|
||||
}
|
||||
// Toggle once for a matched left click; never activate on a stale release.
|
||||
auto activate_entry=[&](EntryHold::Action action){
|
||||
if(action==EntryHold::Action::Idle)return;
|
||||
toggle_entry(action==EntryHold::Action::Launcher);
|
||||
if(menu_open&&views.count("menu"))views.at("menu")->host->set_focus(views.at("menu")->host,1);
|
||||
};
|
||||
vr::VREvent_t event{};while(overlays->PollNextOverlayEvent(button,&event,sizeof(event))){
|
||||
laser_cursor(button,event);
|
||||
if(event.eventType==vr::VREvent_MouseMove||event.eventType==vr::VREvent_MouseButtonDown||event.eventType==vr::VREvent_MouseButtonUp)entry_pointer.move(event.trackedDeviceIndex,event.data.mouse.x,event.data.mouse.y);
|
||||
if(event.eventType==vr::VREvent_FocusLeave)entry_pointer.leave();
|
||||
if(event.eventType==vr::VREvent_MouseButtonDown||event.eventType==vr::VREvent_MouseButtonUp){
|
||||
if(!entry_actions_ready&&menu_button.event(event.eventType==vr::VREvent_MouseButtonDown,event.data.mouse.button==vr::VRMouseButton_Left,anchor_ok,now)){
|
||||
toggle_entry(launcher_primary.load());if(menu_open){menu_button.opened(now);if(views.count("menu"))views.at("menu")->host->set_focus(views.at("menu")->host,1);}
|
||||
std::cout<<"Menu "<<(menu_open?"opened":"closed")<<std::endl;
|
||||
}
|
||||
activate_entry(entry_hold.event(event.eventType==vr::VREvent_MouseButtonDown,event.trackedDeviceIndex,event.data.mouse.button,anchor_ok&&(event.trackedDeviceIndex==vr::k_unTrackedDeviceIndexInvalid||vr_system->IsTrackedDeviceConnected(event.trackedDeviceIndex)),entry_pointer.inside,now));
|
||||
}
|
||||
}
|
||||
if(!anchor_ok)entry_pointer.leave();
|
||||
const bool entry_connected=entry_hold.device==vr::k_unTrackedDeviceIndexInvalid||vr_system->IsTrackedDeviceConnected(entry_hold.device);
|
||||
activate_entry(entry_hold.tick(now,anchor_ok&&entry_connected,entry_pointer.inside&&entry_pointer.device==entry_hold.device));
|
||||
for(auto&[key,v]:views)v->stick_scrolling=false;
|
||||
entry_actions(button,anchor_ok);
|
||||
if(launcher_open){bool held=false;for(auto& hand:entry_buttons)for(auto& trigger:hand)held|=trigger.held;launcher_dismiss.observe(held);}
|
||||
@@ -656,7 +660,7 @@ int main(int argc,char** argv){
|
||||
close_keyboard(false);
|
||||
}
|
||||
for(auto i=views.begin();i!=views.end();){auto& v=*i->second;if(v.closed){overlays->ClearOverlayTexture(v.overlay);overlays->DestroyOverlay(v.overlay);if(v.thumbnail)overlays->DestroyOverlay(v.thumbnail);glDeleteTextures(2,v.textures);i=views.erase(i);}else ++i;}
|
||||
bool hovered=anchor_ok&&overlays->IsHoverTargetOverlay(button);if(hovered&&!icon_hover)hover_haptic(button,button_haptic_at);const auto badge=notification_badge_count.load();if(icon_active!=(menu_open||launcher_open)||icon_hover!=hovered||icon_badge!=badge){icon_badge=badge;icon_active=menu_open||launcher_open;pixels=dock_icon(hovered,icon_active);overlays->SetOverlayRaw(button,pixels.data(),dock_canvas_width,dock_canvas_height,4);}icon_hover=hovered;
|
||||
bool hovered=anchor_ok&&overlays->IsHoverTargetOverlay(button);if(hovered&&!icon_hover)hover_haptic(button,button_haptic_at);const auto badge=notification_badge_count.load();const int progress=std::lround(entry_hold.progress(now)*120);if(icon_progress!=progress||icon_active!=(menu_open||launcher_open)||icon_hover!=hovered||icon_badge!=badge){icon_progress=progress;icon_badge=badge;icon_active=menu_open||launcher_open;pixels=dock_icon(hovered,icon_active,progress/120.f);overlays->SetOverlayRaw(button,pixels.data(),dock_canvas_width,dock_canvas_height,4);}icon_hover=hovered;
|
||||
auto sync=overlays->WaitFrameSync(8);
|
||||
// A timeout already waited: never append another sleep to it. Bound retries
|
||||
// only for immediate API failures, so a broken sync call cannot busy-spin.
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
#include "entry_hold.h"
|
||||
#include <cassert>
|
||||
#include <iostream>
|
||||
|
||||
int main(){
|
||||
using A=EntryHold::Action;
|
||||
using namespace std::chrono;
|
||||
const auto start=EntryHold::Clock::time_point{}+seconds(10);
|
||||
// All mouse buttons and runtime-supplied input (without a tracked device).
|
||||
for(unsigned device:{1u,2u,~0u})for(unsigned button:{1u,2u,4u}){
|
||||
EntryHold hold;
|
||||
assert(hold.event(true,device,button,true,true,start)==A::Idle);
|
||||
assert(hold.event(false,device,button,true,true,start+milliseconds(100))==A::Launcher);
|
||||
assert(hold.event(false,device,button,true,true,start+milliseconds(110))==A::Idle);
|
||||
}
|
||||
EntryHold hold;
|
||||
hold.event(true,1,1,true,true,start);
|
||||
assert(std::abs(hold.progress(start+milliseconds(300))-.5f)<.001f);
|
||||
assert(hold.tick(start+milliseconds(599),true,true)==A::Idle);
|
||||
assert(hold.tick(start+milliseconds(600),true,true)==A::QuickPanel);
|
||||
assert(hold.tick(start+milliseconds(900),true,true)==A::Idle);
|
||||
assert(hold.event(false,1,1,true,true,start+seconds(1))==A::Idle);
|
||||
assert(hold.progress(start+seconds(1))==0);
|
||||
// Crossing the threshold between frames still resolves exclusively as a hold.
|
||||
hold.event(true,1,1,true,true,start);
|
||||
assert(hold.event(false,1,1,true,true,start+milliseconds(600))==A::QuickPanel);
|
||||
hold.event(true,1,1,true,true,start);
|
||||
assert(hold.event(false,2,1,true,true,start+milliseconds(100))==A::Idle);
|
||||
assert(hold.event(false,1,2,true,true,start+milliseconds(110))==A::Idle);
|
||||
assert(hold.event(false,1,1,true,true,start+milliseconds(120))==A::Launcher);
|
||||
// Small jitter does not restart the timer; a sustained leave or loss cancels.
|
||||
hold.event(true,1,1,true,true,start);
|
||||
hold.tick(start+milliseconds(480),true,true);
|
||||
assert(hold.tick(start+milliseconds(550),true,false)==A::Idle);
|
||||
assert(hold.tick(start+milliseconds(610),true,true)==A::QuickPanel);
|
||||
hold.cancel();hold.event(true,1,1,true,true,start);
|
||||
hold.tick(start+milliseconds(151),true,false);
|
||||
assert(!hold.pressed);
|
||||
assert(hold.event(false,1,1,true,true,start+seconds(1))==A::Idle);
|
||||
for(bool inside:{false,true}){
|
||||
hold.event(true,1,1,true,true,start);
|
||||
assert(hold.tick(start+milliseconds(600),false,inside)==A::Idle);
|
||||
assert(!hold.pressed);
|
||||
}
|
||||
hold.event(true,1,1,true,false,start);
|
||||
assert(hold.tick(start+seconds(1),true,true)==A::Idle);
|
||||
hold.event(true,1,1,true,true,start);
|
||||
assert(hold.event(false,1,1,true,false,start+milliseconds(100))==A::Idle);
|
||||
// The ring never alters the central logo or texture alpha; only its perimeter.
|
||||
std::vector<uint8_t> pixels(128*128*4,23),original=pixels;
|
||||
entry_progress_ring(pixels,0);assert(pixels==original);
|
||||
entry_progress_ring(pixels,.5f);
|
||||
assert(pixels!=original);
|
||||
for(int y=0;y<128;y++)for(int x=0;x<128;x++){
|
||||
int i=(y*128+x)*4;
|
||||
assert(pixels[i+3]==original[i+3]);
|
||||
if(std::hypot(x-63.5f,y-63.5f)<55.f)for(int c=0;c<4;c++)assert(pixels[i+c]==original[i+c]);
|
||||
}
|
||||
auto partial=pixels;entry_progress_ring(pixels,1);
|
||||
assert(pixels!=partial);
|
||||
std::cout<<"entry_hold: passed\n";
|
||||
}
|
||||
@@ -3,7 +3,7 @@ set -euo pipefail
|
||||
base=$(cd -- "$(dirname -- "$0")/.." && pwd)
|
||||
cd "$base"
|
||||
mkdir -p target/tests
|
||||
for test in menu_geometry keyboard_state notification_geometry notification_badge ui_visibility plugin_navigation launcher_input launcher_gaze gaze_geometry launcher_fade launcher_pointer page_scroll paint_buffer render_timing launcher_priority; do
|
||||
for test in entry_hold menu_geometry keyboard_state notification_geometry notification_badge ui_visibility plugin_navigation launcher_input launcher_gaze gaze_geometry launcher_fade launcher_pointer page_scroll paint_buffer render_timing launcher_priority; do
|
||||
"${CXX:-g++}" -std=c++17 -O2 -pthread -Inative -Inative/vendor/openvr "tests/$test.cpp" -o "target/tests/$test"
|
||||
"target/tests/$test"
|
||||
done
|
||||
@@ -445,8 +445,7 @@
|
||||
"未找到应用": "No matching apps",
|
||||
"本体通知,包括检查更新提醒。": "Framely notifications, including update reminders.",
|
||||
"启动台": "Launcher",
|
||||
"主扳机打开启动台": "Open launcher with the primary trigger",
|
||||
"开启后,上扳机打开快捷面板。": "When enabled, the upper trigger opens the quick panel.",
|
||||
"点击入口图标打开启动台;长按 600 毫秒打开快捷面板。": "Click the entry icon to open the launcher; hold for 600 ms to open the quick panel.",
|
||||
"长按菜单自动关闭": "Automatically close the action menu",
|
||||
"指针离开后开始计时,移入后取消。": "Start the timer when the pointer leaves; cancel it when the pointer returns.",
|
||||
"自动关闭时间(秒)": "Close delay (seconds)",
|
||||
|
||||
@@ -445,8 +445,7 @@
|
||||
"未找到应用": "未找到应用",
|
||||
"本体通知,包括检查更新提醒。": "本体通知,包括检查更新提醒。",
|
||||
"启动台": "启动台",
|
||||
"主扳机打开启动台": "主扳机打开启动台",
|
||||
"开启后,上扳机打开快捷面板。": "开启后,上扳机打开快捷面板。",
|
||||
"点击入口图标打开启动台;长按 600 毫秒打开快捷面板。": "点击入口图标打开启动台;长按 600 毫秒打开快捷面板。",
|
||||
"长按菜单自动关闭": "长按菜单自动关闭",
|
||||
"指针离开后开始计时,移入后取消。": "指针离开后开始计时,移入后取消。",
|
||||
"自动关闭时间(秒)": "自动关闭时间(秒)",
|
||||
|
||||
+1
-1
@@ -29,7 +29,7 @@ export function LauncherSettings({config=defaults,refresh}:{config?:LauncherConf
|
||||
const[busy,setBusy]=useState(false),[error,setError]=useState(''),[seconds,setSeconds]=useState(config.menuTimeoutSeconds);
|
||||
useEffect(()=>setSeconds(config.menuTimeoutSeconds),[config.menuTimeoutSeconds]);
|
||||
async function save(p:Partial<LauncherConfig>){setBusy(true);setError('');try{await api('launcher.settings.save',{...config,...p});await refresh();}catch(e){setError(String(e));}finally{setBusy(false);}}
|
||||
return <><section className="settings-card launcher-settings"><h2>{t('启动方式与菜单')}</h2><SwitchRow label={t('启动台资源优先')} description={t('启动台显示时提高界面 CPU 调度优先级,关闭后恢复。')} checked={config.prioritizeUi??true} disabled={busy} onChange={prioritizeUi=>void save({prioritizeUi})}/><SwitchRow label={t('主扳机打开启动台')} description={t('开启后,上扳机打开快捷面板。')} checked={config.primaryTrigger} disabled={busy} onChange={primaryTrigger=>void save({primaryTrigger})}/><SwitchRow label={t('长按菜单自动关闭')} description={t('指针离开后开始计时,移入后取消。')} checked={config.menuAutoClose} disabled={busy} onChange={menuAutoClose=>void save({menuAutoClose})}/><label><span>{t('自动关闭时间(秒)')}</span><input type="number" min={1} max={300} value={seconds} disabled={busy||!config.menuAutoClose} onChange={e=>setSeconds(+e.target.value)} onBlur={()=>{if(seconds!==config.menuTimeoutSeconds&&seconds>=1&&seconds<=300)void save({menuTimeoutSeconds:seconds});}}/></label>{error&&<p role="alert">{error}</p>}</section><section className="settings-card launcher-settings"><h2>{t('全部包含的分类')}</h2><p className="sub">{t('只影响全部分类,其他分类和收藏仍可访问。')}</p>{categories.filter(c=>!['all','favorites'].includes(c.key)).map(c=><SwitchRow key={c.key} label={t(c.label)} description={c.key==='plugin'?t('包含 Framely'):undefined} checked={(config.allCategories??defaults.allCategories!).includes(c.key)} disabled={busy} onChange={checked=>{const selected=new Set(config.allCategories??defaults.allCategories);if(checked)selected.add(c.key);else selected.delete(c.key);void save({allCategories:[...selected]});}}/>)}</section><section className="settings-card launcher-settings"><h2>{t('眼追校准')}</h2><p className="sub">{t('在 Frame 内打开模拟启动台,依次注视高亮目标。')}</p><div className="actions"><button disabled={busy} onClick={()=>void api('host.launcher.calibrate',{active:true}).catch(e=>setError(String(e)))}>{t('开始校准')}</button><button disabled={busy} onClick={()=>{setBusy(true);void api('launcher.gaze.calibration.save',null).then(()=>{window.dispatchEvent(new Event('framely.gaze.calibration.changed'));return refresh();}).catch(e=>setError(String(e))).finally(()=>setBusy(false));}}>{t('清除校准')}</button></div></section></>;
|
||||
return <><section className="settings-card launcher-settings"><h2>{t('启动方式与菜单')}</h2><SwitchRow label={t('启动台资源优先')} description={t('启动台显示时提高界面 CPU 调度优先级,关闭后恢复。')} checked={config.prioritizeUi??true} disabled={busy} onChange={prioritizeUi=>void save({prioritizeUi})}/><p className="sub">{t('点击入口图标打开启动台;长按 600 毫秒打开快捷面板。')}</p><SwitchRow label={t('长按菜单自动关闭')} description={t('指针离开后开始计时,移入后取消。')} checked={config.menuAutoClose} disabled={busy} onChange={menuAutoClose=>void save({menuAutoClose})}/><label><span>{t('自动关闭时间(秒)')}</span><input type="number" min={1} max={300} value={seconds} disabled={busy||!config.menuAutoClose} onChange={e=>setSeconds(+e.target.value)} onBlur={()=>{if(seconds!==config.menuTimeoutSeconds&&seconds>=1&&seconds<=300)void save({menuTimeoutSeconds:seconds});}}/></label>{error&&<p role="alert">{error}</p>}</section><section className="settings-card launcher-settings"><h2>{t('全部包含的分类')}</h2><p className="sub">{t('只影响全部分类,其他分类和收藏仍可访问。')}</p>{categories.filter(c=>!['all','favorites'].includes(c.key)).map(c=><SwitchRow key={c.key} label={t(c.label)} description={c.key==='plugin'?t('包含 Framely'):undefined} checked={(config.allCategories??defaults.allCategories!).includes(c.key)} disabled={busy} onChange={checked=>{const selected=new Set(config.allCategories??defaults.allCategories);if(checked)selected.add(c.key);else selected.delete(c.key);void save({allCategories:[...selected]});}}/>)}</section><section className="settings-card launcher-settings"><h2>{t('眼追校准')}</h2><p className="sub">{t('在 Frame 内打开模拟启动台,依次注视高亮目标。')}</p><div className="actions"><button disabled={busy} onClick={()=>void api('host.launcher.calibrate',{active:true}).catch(e=>setError(String(e)))}>{t('开始校准')}</button><button disabled={busy} onClick={()=>{setBusy(true);void api('launcher.gaze.calibration.save',null).then(()=>{window.dispatchEvent(new Event('framely.gaze.calibration.changed'));return refresh();}).catch(e=>setError(String(e))).finally(()=>setBusy(false));}}>{t('清除校准')}</button></div></section></>;
|
||||
}
|
||||
type LocalApp={steamAppId?:number|null;id:string;name:string;kind:'desktop'|'lepton';icon?:string;launchUnavailable?:string|null};
|
||||
type LaunchItem={key:string;kind:string;id:string;name:string;icon?:string|null;fit?:string;favorite:boolean;launchUnavailable?:string|null};
|
||||
|
||||
Reference in new issue
Block a user