From 142fd2a345c183ea920fe6a7c90b7249ab1a67d9 Mon Sep 17 00:00:00 2001 From: toorux Date: Tue, 6 Oct 2026 00:59:29 +0800 Subject: [PATCH] fix: prioritize the middle row in launcher layouts --- tests/launcher_fixture.tsx | 6 +++--- ui/src/launcher.tsx | 11 +++++++---- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/tests/launcher_fixture.tsx b/tests/launcher_fixture.tsx index a3dc6a1..78b9086 100644 --- a/tests/launcher_fixture.tsx +++ b/tests/launcher_fixture.tsx @@ -30,19 +30,19 @@ function openMenu(){const b=document.querySelector('.launch-i const discs=[...document.querySelectorAll('.launch-disc')].map(n=>n.getBoundingClientRect());const middle=discs.filter(r=>Math.abs(r.y-discs[4].y)<1);const betweenIcons=middle[1].left-middle[0].right,gapToCategories=middle[0].left-categoryRight;const gapToShortcuts=document.querySelector('.launcher-shortcuts')!.getBoundingClientRect().left-middle[middle.length-1].right;if(betweenIcons<106||gapToCategories<132||gapToShortcuts<132||gapToCategories<=betweenIcons||gapToShortcuts<=betweenIcons)throw Error('Both capsule gaps must exceed the wider horizontal artwork gap '+JSON.stringify({betweenIcons,gapToCategories,gapToShortcuts})); const firstSlotY=document.querySelector('.launch-item')!.offsetTop; const sameLayout=()=>{const g=document.querySelector('.launcher-grid')!.getBoundingClientRect(),f=filters.getBoundingClientRect();if(g.width!==gridBefore.width||g.height!==gridBefore.height||g.x!==gridBefore.x||g.y!==gridBefore.y||f.x!==filterBefore.x||f.y!==filterBefore.y)throw Error('App count moved the fixed grid or categories '+JSON.stringify({before:gridBefore.toJSON(),after:g.toJSON(),filterBefore:filterBefore.toJSON(),filterAfter:f.toJSON()}));}; - const assertRows=(expected:number[])=>{const groups=new Map();for(const item of document.querySelectorAll('.launch-item')){const r=item.querySelector('.launch-icon')!.getBoundingClientRect();if(item.style.gridRow.startsWith('1 /')&&Math.abs(item.offsetTop-firstSlotY)>1)throw Error('Partial rows moved the first icon row');const key=item.style.gridRow;groups.set(key,[...(groups.get(key)??[]),r]);}const rows=[...groups.values()];if(rows.map(row=>row.length).join(',')!==expected.join(','))throw Error('Unexpected row counts '+rows.map(row=>row.length));const center=(r:DOMRect)=>r.x+r.width/2;if(rows.length===1){const mean=rows[0].reduce((sum,r)=>sum+center(r),0)/rows[0].length;if(Math.abs(mean-gridBefore.x-gridBefore.width/2)>1)throw Error('Single row is not centered');}else{const step=center(rows[0][1])-center(rows[0][0]);if(Math.abs(center(rows[1][0])-center(rows[0][0])+step/2)>1)throw Error('Second row did not start half a slot left');if(rows[2]&&Math.abs(center(rows[2][0])-center(rows[0][0]))>1)throw Error('Third row is not aligned with the first');}}; + const assertRows=(expected:number[])=>{const groups=new Map();for(const item of document.querySelectorAll('.launch-item')){const r=item.querySelector('.launch-icon')!.getBoundingClientRect();if(item.style.gridRow.startsWith('1 /')&&Math.abs(item.offsetTop-firstSlotY)>1)throw Error('Partial rows moved the first icon row');const key=item.style.gridRow;groups.set(key,[...(groups.get(key)??[]),r]);}const rows=[...groups.values()];if(rows.map(row=>row.length).join(',')!==expected.join(','))throw Error('Unexpected row counts '+rows.map(row=>row.length));const center=(r:DOMRect)=>r.x+r.width/2;if(rows.length===1){if(![...groups.keys()][0].startsWith('3 /'))throw Error('Single row is not in the middle');const mean=rows[0].reduce((sum,r)=>sum+center(r),0)/rows[0].length;if(Math.abs(mean-gridBefore.x-gridBefore.width/2)>1)throw Error('Single row is not centered');}else{const step=center(rows[1][1])-center(rows[1][0]);if(Math.abs(center(rows[1][0])-center(rows[0][0])+step/2)>1)throw Error('Second row did not start half a slot left');if(rows[2]&&Math.abs(center(rows[2][0])-center(rows[0][0]))>1)throw Error('Third row is not aligned with the first');}}; assertRows([4,5,1]); plugins['demo.ten']={...plugins['demo.beta'],manifest:{...plugins['demo.beta'].manifest,id:'demo.ten',name:'Tenth app'}};redraw();await until(()=>document.querySelectorAll('.launch-icon').length===11);sameLayout();assertRows([4,5,2]);await wait(300);console.log('FRAMELY_PREVIEW_INSTALL_LAUNCHER_TEN_EN');delete plugins['demo.ten'];redraw();await until(()=>document.querySelectorAll('.launch-icon').length===10); for(let n=0;n<3;n++)plugins[`demo.extra${n}`]={...plugins['demo.beta'],manifest:{...plugins['demo.beta'].manifest,id:`demo.extra${n}`,name:`Extra ${n}`}};redraw();await until(()=>document.querySelectorAll('.launch-icon').length===13);sameLayout();assertRows([4,5,4]);for(let n=3;n<7;n++)plugins[`demo.extra${n}`]={...plugins['demo.beta'],manifest:{...plugins['demo.beta'].manifest,id:`demo.extra${n}`,name:`Extra ${n}`}};redraw();await until(()=>document.querySelectorAll('.launcher-pages button').length===2);sameLayout();if(document.querySelectorAll('.launch-icon').length!==13)throw Error('AVP page capacity changed');document.querySelectorAll('.launcher-pages button')[1].click();await until(()=>document.querySelectorAll('.launch-icon').length===4);sameLayout();document.querySelectorAll('.launcher-pages button')[0].click();await until(()=>document.querySelectorAll('.launch-icon').length===13);if(!document.querySelector('.launcher-page')!.getAnimations().length)throw Error('Page change has no animation');await wait(340);for(let n=0;n<7;n++)delete plugins[`demo.extra${n}`];redraw();await until(()=>document.querySelectorAll('.launch-icon').length===10); if(Math.abs(document.querySelector('.launch-disc')!.getBoundingClientRect().width-96)>.1)throw Error('Launcher artwork is too large'); document.querySelector('.launcher-filters button[aria-label=Plugin]')!.click();await until(()=>document.querySelectorAll('.launch-icon').length===3);sameLayout();if(!document.querySelector('.launcher-page')!.getAnimations().length)throw Error('Category change has no animation');const categoryMotion=document.querySelector('.launcher-page')!.getAnimations()[0].effect as KeyframeEffect;if(!String(categoryMotion.getKeyframes()[0].translate).includes('110px'))throw Error('Category motion is too subtle');const activeSurface=document.querySelector('.launcher-category-active')!;await wait(60);const surfaceY=activeSurface.getBoundingClientRect().top,selectedY=document.querySelector('.launcher-filters button.active')!.getBoundingClientRect().top;if(surfaceY<=filterBefore.top+7||surfaceY>=selectedY)throw Error('Category active surface did not slide through intermediate positions');await until(()=>Math.abs(activeSurface.getBoundingClientRect().top-selectedY)<=1); - const arrangements=[[1],[2],[3],[4],[4,1],[4,2],[4,3],[4,4],[4,5],[4,5,1],[4,5,2],[4,5,3],[4,5,4]]; + const arrangements=[[1],[2],[3],[4],[5],[1,5],[2,5],[3,5],[4,5],[4,5,1],[4,5,2],[4,5,3],[4,5,4]]; plugins['demo.alpha'].enabled=false;plugins['demo.beta'].enabled=false; redraw();await until(()=>document.querySelectorAll('.launch-icon').length===1);sameLayout();assertRows([1]); for(let n=1;n<13;n++){plugins[`layout.${n}`]={...plugins['demo.beta'],enabled:true,manifest:{...plugins['demo.beta'].manifest,id:`layout.${n}`,name:`Layout ${String(n).padStart(2,'0')}`}};redraw();await until(()=>document.querySelectorAll('.launch-icon').length===n+1);sameLayout();assertRows(arrangements[n]);} for(let n=1;n<=13;n++)delete plugins[`layout.${n}`];plugins['demo.alpha'].enabled=true;plugins['demo.beta'].enabled=true;redraw();await until(()=>document.querySelectorAll('.launch-icon').length===3); document.querySelector('.launcher-filters button[aria-label=All]')!.click();await until(()=>document.querySelectorAll('.launch-icon').length===10); - const category=document.querySelector('.launcher-filters button[aria-label=Steam]')!;const categoryRect=category.getBoundingClientRect();console.log(`FRAMELY_LAUNCHER_PRESS_${Math.round(categoryRect.x+categoryRect.width/2)}_${Math.round(categoryRect.y+categoryRect.height/2)}_28_15_150`);await until(()=>document.querySelectorAll('.launch-icon').length===7);sameLayout();assertRows([4,3]);document.querySelector('.launcher-filters button[aria-label=All]')!.click();await until(()=>document.querySelectorAll('.launch-icon').length===10); + const category=document.querySelector('.launcher-filters button[aria-label=Steam]')!;const categoryRect=category.getBoundingClientRect();console.log(`FRAMELY_LAUNCHER_PRESS_${Math.round(categoryRect.x+categoryRect.width/2)}_${Math.round(categoryRect.y+categoryRect.height/2)}_28_15_150`);await until(()=>document.querySelectorAll('.launch-icon').length===7);sameLayout();assertRows([2,5]);document.querySelector('.launcher-filters button[aria-label=All]')!.click();await until(()=>document.querySelectorAll('.launch-icon').length===10); const assertControlsPainted=()=>{for(const node of document.querySelectorAll('.launcher-filters button svg,.launcher-pages button span')){const r=node.getBoundingClientRect();console.log(`FRAMELY_LAUNCHER_REGION_${Math.floor(r.x)}_${Math.floor(r.y)}_${Math.ceil(r.width)}_${Math.ceil(r.height)}`);}}; await wait(500);assertControlsPainted();for(const label of document.querySelectorAll('.launch-name')){const r=label.getBoundingClientRect();console.log(`FRAMELY_LAUNCHER_INK_${Math.floor(r.x)}_${Math.floor(r.y)}_${Math.ceil(r.width)}_${Math.ceil(r.height)}`);}console.log('FRAMELY_PREVIEW_INSTALL_LAUNCHER_EN'); const icon=document.querySelector('.launch-icon[aria-label=Termix]')!; diff --git a/ui/src/launcher.tsx b/ui/src/launcher.tsx index 7f60b67..608ba72 100644 --- a/ui/src/launcher.tsx +++ b/ui/src/launcher.tsx @@ -11,6 +11,10 @@ import {matchesSearch,type SearchIndex} from './launcher-search'; export type LauncherConfig={primaryTrigger:boolean;menuAutoClose:boolean;menuTimeoutSeconds:number;allCategories?:string[]}; const defaults:LauncherConfig={primaryTrigger:true,menuAutoClose:true,menuTimeoutSeconds:10,allCategories:['plugin','steam','lepton','desktop']}; type SteamApp={id:number;name:string;icon?:string;iconFit?:string;kind?:string}; +function launcherCells(total:number){ + const counts=[Math.min(Math.max(total-5,0),4),Math.min(total,5),Math.max(total-9,0)]; + return counts.flatMap((count,row)=>Array.from({length:count},(_,col)=>({row,column:(6-(total<=5?count:row===1?5:4))*2+1+col*4}))); +} function LauncherArtwork({src,name,kind,fit}:{src?:string|null;name:string;kind:string;fit?:string}){ const [failed,setFailed]=useState(false); useEffect(()=>setFailed(false),[src]); @@ -61,8 +65,7 @@ export function Launcher({plugins,safeMode,config=defaults,favorites,localFavori useEffect(()=>{const changed=(e:Event)=>{keyboard.current=!!(e as CustomEvent).detail;if(keyboard.current){cancelPan();cancelHold();setMenu(null);}};window.addEventListener('framely.keyboard',changed);return()=>window.removeEventListener('framely.keyboard',changed);},[]); const items=useMemo(()=>all.filter(a=>(filter==='favorites'?a.favorite:filter==='all'?(config.allCategories??defaults.allCategories!).includes(a.kind==='framely'?'plugin':a.kind):a.kind===filter||(filter==='plugin'&&a.kind==='framely'))&&matchesSearch(a.name,query,indexes[a.name])),[all,filter,config.allCategories,query,indexes]); const pageSize=13,columns=6,pages=Math.max(1,Math.ceil(items.length/pageSize)),current=Math.min(page,pages-1),selected=menu?all.find(a=>a.key===menu):null,selectedPlugin=selected?.kind==='plugin'?plugins[selected.id]:null,visible=items.slice(current*pageSize,current*pageSize+pageSize); - const counts=[Math.min(visible.length,4),Math.min(Math.max(visible.length-4,0),5),Math.max(visible.length-9,0)].filter(count=>count>0),rows=Math.max(1,counts.length); - const cells=counts.flatMap((count,row)=>Array.from({length:count},(_,col)=>({row,column:(columns-(rows===1?count:row===1?5:4))*2+1+col*4}))); + const cells=launcherCells(visible.length); state.current={editing,current,pages,filter,panning:!!panView,items,all}; const pageTransition=useRef({current,filter}),pageAnimation=useRef(null); useLayoutEffect(()=>{const previous=pageTransition.current;pageTransition.current={current,filter};if(previous.current===current&&previous.filter===filter)return;pageAnimation.current?.cancel();if(skipPageAnimation.current){skipPageAnimation.current=false;return;}if(window.matchMedia('(prefers-reduced-motion: reduce)').matches||!pageContent.current)return;const category=previous.filter!==filter,direction=category?Math.sign(categories.findIndex(c=>c.key===filter)-categories.findIndex(c=>c.key===previous.filter)):Math.sign(current-previous.current);pageAnimation.current=pageContent.current.animate([{opacity:0,translate:category?`0 ${direction*110}px`:`${direction*110}px 0`,scale:1},{opacity:1,translate:'0 0',scale:1}],{duration:category?360:300,easing:'cubic-bezier(.22,.65,.24,1)'});},[current,filter]); @@ -101,9 +104,9 @@ export function Launcher({plugins,safeMode,config=defaults,favorites,localFavori
{visible.map((a,index)=>
{if(menu===a.key)setOutside(false);}} onPointerLeave={()=>{if(menu===a.key)setOutside(true);}}> {a.name} {!editing&&} - {menu===a.key&&
=(columns-2)*4?'align-right':''} ${rows>1&&cells[index].row===rows-1?'above':''}`} onPointerEnter={()=>setOutside(false)} onPointerLeave={()=>setOutside(true)}> + {menu===a.key&&
=(columns-2)*4?'align-right':''} ${cells[index].row===2?'above':''}`} onPointerEnter={()=>setOutside(false)} onPointerLeave={()=>setOutside(true)}> {selectedPlugin?<>{(selectedPlugin.manifest.ui.launcherActions??[]).map(action=>)}{!!selectedPlugin.manifest.ui.launcherActions?.length&&
}:<>{a.kind==='lepton'&&!a.key.startsWith('steam:')&&<>}} - {busyKeys.has(a.key)&&{t('正在执行…')}}
}
)}
{panView&¤t+panView.direction>=0&¤t+panView.direction
}
+ {busyKeys.has(a.key)&&{t('正在执行…')}}})}{panView&¤t+panView.direction>=0&¤t+panView.direction