Initial Lime window manager
This commit is contained in:
216
work/arrange-mode-concept.html
Normal file
216
work/arrange-mode-concept.html
Normal file
@@ -0,0 +1,216 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user