Files
lime/work/arrange-mode-concept.html
2026-10-02 14:14:47 -05:00

217 lines
16 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<div id="lime-arrange-concept" aria-label="Lime Arrange mode concept">
<style>
#lime-arrange-concept { --lm-bg:light-dark(#eeeae0,#191919); --lm-panel:light-dark(#e2ded4,#252525); --lm-window:light-dark(#faf7ef,#151719); --lm-text:light-dark(#302d28,#d8c9ad); --lm-muted:light-dark(#625e54,#a69e8e); --lm-edge:light-dark(#bcb6a8,#444444); --lm-gold:light-dark(#795323,#c49a62); --lm-sage:light-dark(#486347,#9cae89); color:var(--lm-text); font-family:ui-monospace,monospace; }
#lime-arrange-concept * { box-sizing:border-box; }
#lime-arrange-concept .shell { background:var(--lm-bg); border:1px solid var(--lm-edge); }
#lime-arrange-concept .bar { display:flex; flex-wrap:wrap; align-items:center; gap:.7em; padding:.5em .8em; border-bottom:1px solid var(--lm-edge); }
#lime-arrange-concept .bar .activity { color:var(--lm-gold); }
#lime-arrange-concept .mode { margin-left:auto; color:var(--lm-sage); }
#lime-arrange-concept .guide { padding:.7em .8em; background:var(--lm-panel); border-bottom:1px solid var(--lm-edge); }
#lime-arrange-concept .prompt { margin-bottom:.5em; min-height:1.5em; }
#lime-arrange-concept .actions { display:flex; gap:.4em .6em; flex-wrap:wrap; }
#lime-arrange-concept button { font:inherit; color:var(--lm-text); background:var(--lm-bg); border:1px solid var(--lm-edge); border-radius:2px; padding:.3em .6em; cursor:pointer; }
#lime-arrange-concept button:hover { border-color:var(--lm-gold); }
#lime-arrange-concept button[aria-pressed="true"] { color:var(--lm-sage); border-color:var(--lm-sage); }
#lime-arrange-concept button:disabled { cursor:default; opacity:.5; }
#lime-arrange-concept kbd { color:var(--lm-gold); font:inherit; }
#lime-arrange-concept .desk { height:360px; position:relative; margin:7px; }
#lime-arrange-concept .tile { position:absolute; display:flex; flex-direction:column; align-items:stretch; text-align:left; overflow:hidden; background:var(--lm-window); padding:.7em; transition:left .16s,top .16s,width .16s,height .16s; }
#lime-arrange-concept .tile.selected { border:2px solid var(--lm-gold); }
#lime-arrange-concept .tile:disabled { opacity:1; }
#lime-arrange-concept .tile .caption { display:flex; gap:.6em; align-items:center; white-space:nowrap; overflow:hidden; }
#lime-arrange-concept .tile .title { overflow:hidden; text-overflow:ellipsis; }
#lime-arrange-concept .tile .hint { display:inline-flex; justify-content:center; min-width:1.7em; padding:.1em .3em; background:var(--lm-panel); color:var(--lm-gold); }
#lime-arrange-concept .content { margin-top:1.3em; color:var(--lm-muted); overflow:hidden; white-space:pre; }
#lime-arrange-concept .ghost { position:absolute; border:2px dashed var(--lm-sage); background:color-mix(in srgb,var(--lm-sage) 12%,transparent); pointer-events:none; display:flex; justify-content:center; align-items:center; padding:.6em; text-align:center; color:var(--lm-sage); }
#lime-arrange-concept .handle { position:absolute; z-index:3; padding:0; border:0; background:transparent; touch-action:none; display:flex; align-items:center; justify-content:center; }
#lime-arrange-concept .handle::before { content:""; position:absolute; background:var(--lm-edge); }
#lime-arrange-concept .handle.x { width:24px; transform:translateX(-50%); cursor:ew-resize; }
#lime-arrange-concept .handle.y { height:24px; transform:translateY(-50%); cursor:ns-resize; }
#lime-arrange-concept .handle.x::before { width:2px; height:100%; }
#lime-arrange-concept .handle.y::before { height:2px; width:100%; }
#lime-arrange-concept .handle[aria-pressed="true"]::before { background:var(--lm-sage); }
#lime-arrange-concept .handle span { position:relative; background:var(--lm-panel); border:1px solid var(--lm-sage); padding:.2em .5em; color:var(--lm-sage); }
#lime-arrange-concept .footer { padding:.45em .8em; border-top:1px solid var(--lm-edge); color:var(--lm-muted); overflow-wrap:anywhere; }
@media(max-width:480px) { #lime-arrange-concept .desk { height:400px; } #lime-arrange-concept .content { display:none; } #lime-arrange-concept .tile { padding:.5em; } }
@media(pointer:coarse) { #lime-arrange-concept .actions button { min-height:44px; } #lime-arrange-concept .handle.x { width:44px; } #lime-arrange-concept .handle.y { height:44px; } }
@media(prefers-reduced-motion:reduce) { #lime-arrange-concept .tile { transition:none; } }
</style>
<div class="shell">
<div class="bar"><span>Lime</span><span aria-hidden="true">│</span><span class="activity">WM:Code<span class="dirty"></span> · |M|</span><span class="mode">ARRANGE</span></div>
<div class="guide"><div class="prompt" aria-live="polite"></div><div class="actions"></div></div>
<div class="desk" aria-label="Three-window working layout"></div>
<div class="footer" aria-live="polite">Click a window or type its label. Your applications stay visible.</div>
</div>
<script>
(() => {
const root = document.getElementById('lime-arrange-concept');
const desk = root.querySelector('.desk'), actions = root.querySelector('.actions');
const prompt = root.querySelector('.prompt'), footer = root.querySelector('.footer');
const design = { labels:'numbers' };
const names = {1:'Editor',2:'Browser',3:'Terminal',4:'Next window'};
const contents = {1:'src/layout.lisp\n\n(defun arrange (windows)\n ...)',2:'Lime documentation\n\nActivities and Views',3:'~/code/lime\n\nλ sbcl',4:''};
let tree = {id:'main',axis:'x',ratio:.58,a:{client:1},b:{id:'column',axis:'y',ratio:.52,a:{client:2},b:{client:3}}};
let mode='arrange', selected=1, source=1, target=2, side='right', edge='main', armed=null;
let history=[], splitCounter=0, dragging=null, last='Click a window or type its label. Your applications stay visible.';
const clone = value => JSON.parse(JSON.stringify(value));
const hint = index => design.labels==='numbers' ? String(index) : String.fromCharCode(64+index);
const choose = key => design.labels==='numbers' ? Number(key) : key.toUpperCase().charCodeAt(0)-64;
const has = (node,id) => node.client ? node.client===id : has(node.a,id)||has(node.b,id);
function parts(node=tree,x=0,y=0,w=1,h=1,result={tiles:[],splits:[]}) {
if(node.client) result.tiles.push({id:node.client,x,y,w,h});
else {
result.splits.push({node,x,y,w,h});
if(node.axis==='x') { parts(node.a,x,y,w*node.ratio,h,result); parts(node.b,x+w*node.ratio,y,w*(1-node.ratio),h,result); }
else { parts(node.a,x,y,w,h*node.ratio,result); parts(node.b,x,y+h*node.ratio,w,h*(1-node.ratio),result); }
}
return result;
}
function remove(node,id) {
if(node.client) return node.client===id ? null : node;
const a=remove(node.a,id),b=remove(node.b,id);
return a&&b ? {...node,a,b} : a||b;
}
function replace(node,id,replacement) {
if(node.client) return node.client===id ? replacement : node;
return {...node,a:replace(node.a,id,replacement),b:replace(node.b,id,replacement)};
}
function placed() {
let next=clone(tree);
if(side==='swap') {
function swap(node) { if(node.client) node.client=node.client===source?target:node.client===target?source:node.client; else { swap(node.a);swap(node.b); } }
swap(next); return next;
}
if(source!==4) next=remove(next,source);
const first=side==='left'||side==='above';
const split={id:'edit-'+splitCounter,axis:side==='left'||side==='right'?'x':'y',ratio:.5,
a:{client:first?source:target},b:{client:first?target:source}};
return replace(next,target,split);
}
function remember() { history.push({tree:clone(tree),armed:clone(armed)}); }
function button(key,label,fn,pressed=false,disabled=false) {
const b=document.createElement('button'); b.type='button'; b.dataset.focusKey=key;
const k=document.createElement('kbd');k.textContent=key+' '; b.append(k,document.createTextNode(label));
b.disabled=disabled; if(pressed) b.setAttribute('aria-pressed','true'); b.onclick=fn;actions.append(b);return b;
}
function back() { mode=mode==='side'?(source===4?'arrange':'target'):mode==='arrange'?'closed':'arrange';render(); }
function enter() {
if(mode==='side') {
remember();
if(source===4) { armed={target,side};last='Next tiled window: '+side+' of '+names[target]+'. No empty tile is reserved.'; }
else { tree=placed();splitCounter++;selected=source;armed=null;last='Placement applied to this draft. Arrange stays open.'; }
mode='arrange';
} else if(mode==='closed') mode='arrange';
else mode=mode==='arrange'?'closed':'arrange';
render();
}
function place(newWindow=false) { source=newWindow?4:selected;target=selected;side='right';mode=newWindow?'side':'target';render(); }
function resize() { mode='resize';edge=parts().splits.filter(p=>has(p.node,selected)).at(-1)?.node.id;render(); }
function nudge(delta) { const s=parts().splits.find(p=>p.node.id===edge);if(!s)return;remember();s.node.ratio=Math.max(.2,Math.min(.8,s.node.ratio+delta));last='Divider adjusted. Keep nudging, pick another divider, or go back.';render(); }
function undo() { const old=history.pop();if(!old)return;tree=old.tree;armed=old.armed;mode='arrange';last='Last layout edit undone.';render(); }
function select(id) {
if(mode==='target') {if(id===source)return;target=id;mode='side';side='right';}
else if(mode==='resize') {const s=parts().splits[id-1];if(s)edge=s.node.id;}
else if(mode==='arrange'||mode==='closed') selected=id;
render();
}
function rect(el,p,inset=3) {
el.style.left=`calc(${p.x*100}% + ${inset}px)`;el.style.top=`calc(${p.y*100}% + ${inset}px)`;
el.style.width=`calc(${p.w*100}% - ${inset*2}px)`;el.style.height=`calc(${p.h*100}% - ${inset*2}px)`;
}
function render() {
const active=root.contains(document.activeElement), focusKey=document.activeElement?.dataset.focusKey;
actions.replaceChildren();desk.replaceChildren();
root.querySelector('.mode').textContent=mode==='closed'?'WORK':mode==='side'?'ARRANGE · PREVIEW':'ARRANGE';
root.querySelector('.dirty').textContent=history.length?'*':'';
if(mode==='closed') {prompt.textContent='Layout edits remain in the draft; the saved View is unchanged.';button('Enter','Arrange',enter);}
if(mode==='arrange') {
prompt.textContent=names[selected]+' selected · click a window or type its label';
button('P','Place',()=>place());button('R','Resize',resize);button('N','Next window',()=>place(true));
button('U','Undo',undo,false,!history.length);button('Esc','Done',back);
}
if(mode==='target') {prompt.textContent='Place '+names[source]+' relative to which window?';button('Esc','Back',back);}
if(mode==='side') {
prompt.textContent=(source===4?'Next window':names[source])+' '+(side==='swap'?'↔':side+' of')+' '+names[target];
['left','right','above','below',...(source===4?[]:['swap'])].forEach((value,i)=>button(String(i+1),value,()=>{side=value;render();},side===value));
button('Enter',source===4?'Set next-window hint':'Apply',enter);button('Esc','Back',back);
}
if(mode==='resize') {
const s=parts().splits.find(p=>p.node.id===edge);
prompt.textContent='Pick a divider · drag it or use − / + repeatedly';
button('−',s?.node.axis==='x'?'←':'↑',()=>nudge(-.04));button('+',s?.node.axis==='x'?'→':'↓',()=>nudge(.04));
button('Enter','Back to Arrange',enter);button('Esc','Back',back);
}
const displayTree=mode==='side'?placed():tree;
parts(displayTree).tiles.forEach(p=>{
const b=document.createElement('button');b.type='button';b.className='tile'+(p.id===selected?' selected':'');
b.dataset.focusKey='window-'+p.id;rect(b,p);b.setAttribute('aria-label',names[p.id]+(mode==='target'?' destination':' window'));
b.disabled=mode==='side'||mode==='resize'||(mode==='target'&&p.id===source);
const caption=document.createElement('span');caption.className='caption';
if(mode==='arrange'||mode==='target') {const h=document.createElement('span');h.className='hint';h.textContent=hint(p.id);caption.append(h);}
const title=document.createElement('span');title.className='title';title.textContent=names[p.id];caption.append(title);
const content=document.createElement('span');content.className='content';content.textContent=contents[p.id];
b.append(caption,content);b.onclick=()=>select(p.id);desk.append(b);
});
if(mode==='resize') parts().splits.forEach((p,i)=>{
const b=document.createElement('button');b.type='button';b.className='handle '+p.node.axis;b.dataset.focusKey='divider-'+i;
b.setAttribute('aria-label','Resize divider '+hint(i+1));b.setAttribute('aria-pressed',String(edge===p.node.id));
const label=document.createElement('span');label.textContent=hint(i+1);b.append(label);
if(p.node.axis==='x') {b.style.left=(p.x+p.w*p.node.ratio)*100+'%';b.style.top=p.y*100+'%';b.style.height=p.h*100+'%';}
else {b.style.top=(p.y+p.h*p.node.ratio)*100+'%';b.style.left=p.x*100+'%';b.style.width=p.w*100+'%';}
b.onclick=()=>{edge=p.node.id;render();};
b.onpointerdown=e=>{if(e.button!==0)return;e.preventDefault();edge=p.node.id;remember();dragging=p;root.setPointerCapture(e.pointerId);render();};desk.append(b);
});
if(armed&&(mode==='arrange'||mode==='closed')) {
const p={...parts().tiles.find(t=>t.id===armed.target)};
if(armed.side==='left'||armed.side==='right'){p.w/=2;if(armed.side==='right')p.x+=p.w;}
else {p.h/=2;if(armed.side==='below')p.y+=p.h;}
const ghost=document.createElement('div');ghost.className='ghost';ghost.textContent='Next window';rect(ghost,p,6);desk.append(ghost);
}
footer.textContent=mode==='side'?'Preview only · Enter applies · Escape backs out':last;
if(active) {
const match=[...root.querySelectorAll('button')].find(b=>b.dataset.focusKey===focusKey&&!b.disabled);
(match||actions.querySelector('button:not(:disabled)'))?.focus({preventScroll:true});
}
}
root.addEventListener('pointermove',e=>{
if(!dragging)return;const d=desk.getBoundingClientRect(),p=dragging;
const coordinate=p.node.axis==='x'?(e.clientX-d.left)/d.width:(e.clientY-d.top)/d.height;
p.node.ratio=Math.max(.2,Math.min(.8,(coordinate-(p.node.axis==='x'?p.x:p.y))/(p.node.axis==='x'?p.w:p.h)));
render();
});
root.addEventListener('pointerup',e=>{dragging=null;if(root.hasPointerCapture(e.pointerId))root.releasePointerCapture(e.pointerId);});
root.addEventListener('pointercancel',()=>{dragging=null;});
root.addEventListener('keydown',e=>{
// Tab/Enter retain native button navigation and activation. After a
// typed command, focus the contextual Enter action when one exists.
if(e.altKey||e.ctrlKey||e.metaKey||e.key==='Tab'||e.key==='Enter')return;
const key=e.key.toLowerCase();let fn;
if(key==='escape')fn=back;
else if(mode==='arrange'&&key==='p')fn=()=>place();
else if(mode==='arrange'&&key==='n')fn=()=>place(true);
else if(mode==='arrange'&&key==='r')fn=resize;
else if(mode==='arrange'&&key==='u')fn=undo;
else if(mode==='resize'&&(key==='-'||key==='−'))fn=()=>nudge(-.04);
else if(mode==='resize'&&(key==='+'||key==='='))fn=()=>nudge(.04);
else if(mode==='side'&&/^[1-5]$/.test(key)) {const next=['left','right','above','below','swap'][Number(key)-1];if(!(source===4&&next==='swap'))fn=()=>{side=next;render();};}
else if(key.length===1&&(mode==='arrange'||mode==='target'||mode==='resize')) {
const id=choose(key),count=mode==='resize'?parts().splits.length:3;
if(id>=1&&id<=count)fn=()=>select(id);
}
if(fn) {
e.preventDefault();
if(!e.repeat||mode==='resize') {
fn();
actions.querySelector('[data-focus-key="Enter"]')?.focus({preventScroll:true});
}
}
});
render();
if(globalThis.Tweak) {
const tweak=new Tweak({container:root,onChange:render});
tweak.addSelect(design,'labels',{label:'Target hints',options:[{label:'Numbers',value:'numbers'},{label:'Letters',value:'letters'}]});
}
})();
</script>
</div>