编辑器、终端、浏览器、文档——四个工具,四扇窗口,屏幕上堆成一层层纸牌。写代码时切到终端看日志,切回编辑器改两行,再切到浏览器查 API,再切到文档确认参数……一天下来 Alt+Tab 按了几百次,上下文在每次切换中碎成渣。有人终于受不了了,造了一个叫 Cate 的项目——类 Figma 风格的无限画布 IDE,所有窗口住在同一块画布上,拖拽、缩放、平移,不再切换。
窗口切换到底浪费了什么
不是时间,是注意力。每次 Alt+Tab,大脑要做三件事:记住当前上下文、识别新窗口内容、重新聚焦。认知科学里这叫"注意力重定向成本",单次约 0.5–1 秒,看似微不足道,但高频场景下累积效应惊人——一天切换 200 次,就是 2–3 分钟纯损耗,更关键的是思维连贯性被打断。
传统窗口管理器的模型是"重叠纸牌":窗口 Z 轴堆叠,只有最上层完全可见。平铺窗口管理器(i3、sway)把纸牌变成网格,但网格有刚性——每个窗口占固定格子,空间利用率低,大窗口和小窗口无法共存。
画布模型不一样。它把 Z 轴问题变成 XY 轴问题:窗口不再重叠抢占同一块屏幕,而是平铺在无限延展的二维平面上。想看编辑器?平移过去。想同时看日志和代码?把终端拖到编辑器旁边。需要参考文档?缩小文档窗口钉在角落。空间是连续的,布局是自由的。
Cate 的核心设计
Cate 把整个工作区当作一张 Figma 式画布。每个"窗口"实际上是画布上的一个节点(node),具备以下能力:
- 自由定位:拖拽到画布任意位置,没有网格约束。
- 缩放:重要窗口放大看细节,参考窗口缩小当背景。
- 分组:相关窗口圈在一起,整体移动。
- 无限画布:平移到任意远处,空间永不枯竭。
这意味着你可以在同一视野内同时看到代码、运行结果和文档,而不需要切换。信息密度由你自己控制,而不是被窗口管理器的规则控制。
从纸牌到画布:一个最小实现
理解概念最快的方式是动手。下面用一个最小 HTML + JS 画布,模拟"无限画布上放窗口节点"的核心机制——平移、缩放、拖拽节点。保存为 canvas-ide.html,浏览器直接打开即可运行:
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<title>Mini Canvas IDE</title>
<style>
* { margin: 0; box-sizing: border-box; }
body { overflow: hidden; background: #1a1a2e; font-family: sans-serif; }
#viewport { width: 100vw; height: 100vh; position: relative; cursor: grab; }
#viewport.grabbing { cursor: grabbing; }
#canvas { position: absolute; transform-origin: 0 0; }
.node {
position: absolute; background: #16213e; border: 1px solid #0f3460;
border-radius: 6px; min-width: 220px; min-height: 140px;
color: #e0e0e0; padding: 8px; cursor: move; user-select: none;
}
.node-title {
font-size: 12px; color: #53a8b6; margin-bottom: 4px;
border-bottom: 1px solid #0f3460; padding-bottom: 4px;
}
.node-body { font-size: 11px; line-height: 1.5; white-space: pre-wrap; }
</style>
</head>
<body>
<div id="viewport">
<div id="canvas"></div>
</div>
<script>
// ---- 画布状态 ----
let panX = 0, panY = 0, scale = 1;
let isPanning = false, panStartX, panStartY;
// ---- 节点数据 ----
const nodes = [
{ id: 'editor', title: '编辑器 — main.py',
body: 'def handle(req):\n return process(req)',
x: 40, y: 30, w: 280, h: 160 },
{ id: 'terminal', title: '终端 — 日志',
body: '[INFO] server started\n[WARN] timeout on /api\n[OK] request handled',
x: 360, y: 30, w: 260, h: 160 },
{ id: 'docs', title: '文档 — API 参考',
body: 'handle(req)\n req: Request object\n returns: Response',
x: 40, y: 220, w: 240, h: 140 },
{ id: 'browser', title: '浏览器 — localhost:8000',
body: '{"status": "ok",\n "data": [1, 2, 3]}',
x: 320, y: 220, w: 300, h: 140 },
];
const canvas = document.getElementById('canvas');
const viewport = document.getElementById('viewport');
// ---- 渲染节点 ----
function renderNodes() {
canvas.innerHTML = '';
nodes.forEach(n => {
const el = document.createElement('div');
el.className = 'node';
el.dataset.id = n.id;
el.style.left = n.x + 'px';
el.style.top = n.y + 'px';
el.style.width = n.w + 'px';
el.style.height = n.h + 'px';
el.innerHTML = `<div class="node-title">${n.title}</div><div class="node-body">${n.body}</div>`;
canvas.appendChild(el);
});
}
// ---- 更新画布变换 ----
function updateTransform() {
canvas.style.transform = `translate(${panX}px, ${panY}px) scale(${scale})`;
}
// ---- 画布平移(鼠标拖拽空白区域) ----
viewport.addEventListener('pointerdown', e => {
if (e.target === viewport || e.target === canvas) {
isPanning = true;
panStartX = e.clientX - panX;
panStartY = e.clientY - panY;
viewport.classList.add('grabbing');
}
});
window.addEventListener('pointermove', e => {
if (isPanning) {
panX = e.clientX - panStartX;
panY = e.clientY - panStartY;
updateTransform();
}
});
window.addEventListener('pointerup', () => {
isPanning = false;
viewport.classList.remove('grabbing');
});
// ---- 画布缩放(鼠标滚轮) ----
viewport.addEventListener('wheel', e => {
e.preventDefault();
const delta = e.deltaY > 0 ? -0.05 : 0.05;
const newScale = Math.min(Math.max(scale + delta, 0.2), 3);
// 以鼠标位置为缩放中心
const mx = e.clientX, my = e.clientY;
panX = mx - (mx - panX) * (newScale / scale);
panY = my - (my - panY) * (newScale / scale);
scale = newScale;
updateTransform();
}, { passive: false });
// ---- 节点拖拽 ----
let dragNode = null, dragOffX, dragOffY;
canvas.addEventListener('pointerdown', e => {
const el = e.target.closest('.node');
if (!el) return;
dragNode = nodes.find(n => n.id === el.dataset.id);
dragOffX = e.clientX / scale - dragNode.x - panX / scale;
dragOffY = e.clientY / scale - dragNode.y - panY / scale;
el.style.zIndex = 10;
});
window.addEventListener('pointermove', e => {
if (!dragNode) return;
dragNode.x = e.clientX / scale - dragOffX - panX / scale;
dragNode.y = e.clientY / scale - dragOffY - panY / scale;
renderNodes();
updateTransform();
});
window.addEventListener('pointerup', () => {
if (dragNode) {
dragNode = null;
renderNodes(); // 重置 zIndex
updateTransform();
}
});
// ---- 初始化 ----
renderNodes();
updateTransform();
</script>
</body>
</html>
打开后你会看到四个模拟窗口节点铺在深色画布上。鼠标拖空白区域平移画布,滚轮缩放,拖节点重新布局。这就是画布 IDE 的最小内核——不到 120 行代码,核心机制已经完整:无限平移 + 缩放 + 节点自由拖拽。
可以改造的方向:把 node-body 换成真实 iframe 嵌入终端或编辑器;加节点分组和迷你地图;接入 WebSocket 让终端节点变成真实会话。
画布模型的真实代价
画布不是银弹。它解决了切换问题,但引入新问题:
- 导航成本:无限画布上找东西,需要迷你地图(minimap)或快速跳转。Figma 用左下角迷你地图解决,Cate 也需要同类机制。
- 焦点管理:所有窗口同时可见,意味着注意力可能分散。需要"聚焦模式"——高亮当前工作节点,淡化其余。
- 输入路由:键盘事件该发给哪个节点?画布上同时显示编辑器和终端,按键必须精确路由到活跃节点,不能模糊。
- 性能:DOM 节点数量随窗口增长,缩放后大区域离屏渲染仍占内存。Figma 用 WebGL 渲染引擎绕过这个问题,Cate 如果走 DOM 路线,迟早要面对性能天花板。
这些不是"能不能做"的问题,而是"做到多好"的问题。Cate 作为早期项目,优先解决了最痛的切换痛点,后续迭代必然要补上导航和焦点。
现阶段可以怎么用
如果你现在就想减少 Alt+Tab,不一定等 Cate 成熟,有几条立即可走的路:
| 方案 | 适合场景 | 限制 |
|---|---|---|
| Cate | 想要画布自由布局、愿意用早期项目 | 功能尚不完整,生态空白 |
| tmux + 终端编辑器 | 纯终端工作流(vim/helix) | 只管终端内窗口,不含浏览器 |
| i3/sway(平铺 WM) | Linux 用户、接受刚性网格 | 布局不够自由,学习曲线陡 |
| VS Code 内嵌终端 | 编辑器为主、偶尔看日志 | 浏览器和文档仍需外部窗口 |
如果你在 Linux 上且愿意尝试平铺窗口管理器,一条快速上手命令:
# Ubuntu 上安装 sway(Wayland 平铺 WM)+ foot 终端
sudo apt install sway foot
# 启动 sway(在登录界面选择,或从终端执行)
sway
# sway 内默认配置已可用,自定义布局编辑 ~/.config/sway/config
# 示例:左边 70% 编辑器,右边 30% 终端
bindsym $mod+1 layout splith ; exec code ; exec foot
平铺 WM 是画布 IDE 的"低配版"——空间连续性不如画布,但切换成本已经大幅下降。
画布 IDE 的真正价值不在于"更酷的布局",而在于恢复工作时的注意力连贯性。当编辑器、终端、文档在同一视野内各据其位,你不再需要记忆"刚才我在看什么"——因为一切都在眼前。Cate 是这个方向的早期探索,概念验证已经完成,接下来要看导航、焦点、性能这些硬问题能解到什么程度。如果你受够了 Alt+Tab,现在就可以用上面的最小画布原型体验核心交互,或用 tmux/sway 先把切换成本降下来。