<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
  <author>
    <name>why</name>
  </author>
  <generator uri="https://hexo.io/">Hexo</generator>
  <id>https://wwhhyyy.com/</id>
  <link href="https://wwhhyyy.com/" rel="alternate"/>
  <link href="https://wwhhyyy.com/atom.xml" rel="self"/>
  <rights>All rights reserved 2026, why</rights>
  <subtitle>记录 · 分享 · 成长</subtitle>
  <title>why 的小站</title>
  <updated>2026-11-02T15:00:28.920Z</updated>
  <entry>
    <author>
      <name>why</name>
    </author>
    <category term="生活" scheme="https://wwhhyyy.com/categories/%E7%94%9F%E6%B4%BB/"/>
    <category term="随笔" scheme="https://wwhhyyy.com/tags/%E9%9A%8F%E7%AC%94/"/>
    <content>
      <![CDATA[<h2 id="about-me"><a href="#about-me" class="headerlink" title="about me"></a>about me</h2><p>1，大连理工在读（大连离婚大学）<br>2，目标是全栈工程师！</p><h2 id="my-project"><a href="#my-project" class="headerlink" title="my project"></a>my project</h2><p>正在开发一个claude桌宠，用c++和qt实现</p><h2 id="Running-on-github"><a href="#Running-on-github" class="headerlink" title="Running on github"></a>Running on github</h2><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">$ whywhy1352.github.io</span><br></pre></td></tr></table></figure><h2 id="today"><a href="#today" class="headerlink" title="today"></a>today</h2><p>10&#x2F;17</p>]]>
    </content>
    <id>https://wwhhyyy.com/2026/11/02/hello-world/</id>
    <link href="https://wwhhyyy.com/2026/11/02/hello-world/"/>
    <published>2026-11-02T15:00:28.920Z</published>
    <summary>
      <![CDATA[<h2 id="about-me"><a href="#about-me" class="headerlink" title="about me"></a>about me</h2><p>1，大连理工在读（大连离婚大学）<br>2，目标是全栈工程师！</p>
<h2 id="my]]>
    </summary>
    <title>初めてのブログ</title>
    <updated>2026-11-02T15:00:28.920Z</updated>
  </entry>
  <entry>
    <author>
      <name>why</name>
    </author>
    <content>
      <![CDATA[<blockquote><p>ClauPet 是一只住在 Windows 桌面上的小宠物，它会根据 Claude Code 正在做什么切换表情：你发消息时它开始工作，Claude 等你授权时它举手，出错了它会着急。</p><p>项目地址：<a href="https://github.com/whywhy1352/ClauPet">https://github.com/whywhy1352/ClauPet</a></p><p>这个项目的架构是我和 Claude 一起讨论出来的，代码是我自己一行行写的。写这篇文章，是想确认我真的理解了每个设计背后的”为什么”。读这篇文章只需要 C++ 基础，不需要 Qt 经验。</p></blockquote><hr><h2 id="一张图看懂整体结构"><a href="#一张图看懂整体结构" class="headerlink" title="一张图看懂整体结构"></a>一张图看懂整体结构</h2><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br></pre></td><td class="code"><pre><span class="line">┌──────────────┐  HTTP POST（JSON）   ┌──────────────────────────────────────────┐</span><br><span class="line">│ Claude Code  │ ───────────────────▶ │ ClauPet（Petwindow）                      │</span><br><span class="line">│ （hooks 配置）│ ◀─────────────────── │                                          │</span><br><span class="line">└──────────────┘     200 OK          │  QTcpServer ─▶ tryReadBody ─▶ handleHookEvent</span><br><span class="line">                                     │                                │          │</span><br><span class="line">                                     │      m_sessionTitles ◀─────────┤          │</span><br><span class="line">                                     │      m_sessionStates ◀─────────┘          │</span><br><span class="line">                                     │             │                             │</span><br><span class="line">                                     │       combinedState()                     │</span><br><span class="line">                                     │             ▼                             │</span><br><span class="line">   鼠标悬停 / 拖动 / 单击 ───────────▶│  setState() / m_dragging / 闹钟           │</span><br><span class="line">                                     │             │  update()（预约重画）        │</span><br><span class="line">                                     │             ▼                             │</span><br><span class="line">                                     │        paintEvent()  ──▶ 屏幕上的宠物      │</span><br><span class="line">                                     └──────────────────────────────────────────┘</span><br></pre></td></tr></table></figure><p>整个程序只有一个核心类 <code>Petwindow</code>（继承自 Qt 的 <code>QWidget</code>），加上一个 <code>main.cpp</code>。技术栈是 C++ + Qt 6 + CMake。</p><p>下面我按”程序从启动到退出”的顺序，把每一步走一遍。</p><hr><h2 id="最重要的一个概念：登记，和执行"><a href="#最重要的一个概念：登记，和执行" class="headerlink" title="最重要的一个概念：登记，和执行"></a>最重要的一个概念：登记，和执行</h2><p>在讲具体流程之前，先讲一个贯穿全文的概念。不理解它，后面每一步都会看糊涂。</p><p>看这段代码：</p><figure class="highlight cpp"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line"><span class="built_in">connect</span>(&amp;m_server, &amp;QTcpServer::newConnection, <span class="keyword">this</span>, [<span class="keyword">this</span>]()</span><br><span class="line">&#123;</span><br><span class="line"><span class="comment">// 处理新连接……</span></span><br><span class="line">&#125;);</span><br></pre></td></tr></table></figure><p>执行到这一句时，lambda 里面的代码<strong>一行都没有执行</strong>。<code>connect</code> 只是告诉 Qt：”<strong>以后</strong>有新连接进来时，执行这段代码。”我把这叫作”<strong>登记</strong>“。</p><p>那登记的代码什么时候执行？答案在 <code>main</code> 的最后一行：</p><figure class="highlight cpp"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><span class="line"><span class="function"><span class="type">int</span> <span class="title">main</span><span class="params">(<span class="type">int</span> argc, <span class="type">char</span>* argv[])</span></span></span><br><span class="line"><span class="function"></span>&#123;</span><br><span class="line"><span class="function">QApplication <span class="title">app</span><span class="params">(argc, argv)</span></span>;</span><br><span class="line">app.<span class="built_in">setQuitOnLastWindowClosed</span>(<span class="literal">false</span>);<span class="comment">// 宠物隐藏后程序不退出，留在托盘里</span></span><br><span class="line">Petwindow pet&#123;&#125;;<span class="comment">// 构造函数里做完所有&quot;登记&quot;</span></span><br><span class="line"><span class="comment">// ……检查端口，见后文……</span></span><br><span class="line">pet.<span class="built_in">show</span>();<span class="comment">// 只是告诉 Qt&quot;要显示&quot;，这时还没画出来</span></span><br><span class="line"><span class="keyword">return</span> app.<span class="built_in">exec</span>();<span class="comment">// 进入事件循环，程序真正开始运行</span></span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p><code>exec()</code> 会进入<strong>事件循环</strong>：不停地等待事件（新连接、鼠标移动、定时器到点、窗口需要重画……），来一个，就去执行当初为它登记的代码，执行完再回来继续等，直到有人调用 <code>quit()</code>。</p><p>打个比方：构造函数是餐厅<strong>开门前</strong>写好规章制度（”客人来了怎么接待””电话响了谁去接”），<code>exec()</code> 是<strong>开门营业</strong>，按规章处理一件件发生的事。</p><p>记住这一点：<strong>程序大部分时间都在事件循环里”等”，有事发生才执行对应的代码。</strong></p><hr><h2 id="第零幕：启动"><a href="#第零幕：启动" class="headerlink" title="第零幕：启动"></a>第零幕：启动</h2><h3 id="构造函数只当”目录”"><a href="#构造函数只当”目录”" class="headerlink" title="构造函数只当”目录”"></a>构造函数只当”目录”</h3><figure class="highlight cpp"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><span class="line">Petwindow::<span class="built_in">Petwindow</span>()</span><br><span class="line">&#123;</span><br><span class="line"><span class="built_in">setUpWindow</span>();<span class="comment">// 窗口大小、无边框、置顶、不在任务栏显示、背景透明</span></span><br><span class="line"><span class="built_in">setUpAnimation</span>();<span class="comment">// 准备浮动动画的&quot;闹钟&quot;和&quot;秒表&quot;</span></span><br><span class="line"><span class="built_in">restorePosition</span>();<span class="comment">// 恢复上次保存的位置</span></span><br><span class="line"><span class="built_in">loadImages</span>();<span class="comment">// 加载 5 张宠物图片</span></span><br><span class="line"><span class="built_in">setUpServer</span>();<span class="comment">// 开始监听端口，登记处理请求的代码</span></span><br><span class="line"><span class="built_in">setUpTrayMenu</span>();<span class="comment">// 创建右键菜单和托盘图标</span></span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>构造函数本身只有 6 行，每一步的细节都放在单独的私有函数里。读代码的人一眼就能看出启动时做了哪几件事。</p><p>这 6 步里只有一个顺序要求：<strong><code>loadImages</code> 必须在 <code>setUpTrayMenu</code> 之前</strong>，因为托盘图标用的就是”空闲”那张图。反过来的话，托盘里会是一个空白图标。其他几步互不影响。</p><p>几个值得一提的细节：</p><ul><li><strong>窗口比宠物高</strong>：宠物是 180×180，但窗口上下各多留了浮动的幅度，顶部还多留了气泡的高度。背景是透明的，多出来的地方看不见，点击也会穿透过去。</li><li><strong>恢复位置时会检查屏幕</strong>：上次如果把宠物拖到了副屏，这次副屏没接，就不恢复，否则宠物会出现在看不见的地方。</li></ul><figure class="highlight cpp"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">if</span> (QGuiApplication::<span class="built_in">screenAt</span>(savedPos + <span class="built_in">QPoint</span>(kPetSize / <span class="number">2</span>, kPetSize / <span class="number">2</span>)) != <span class="literal">nullptr</span>)</span><br><span class="line">&#123;</span><br><span class="line"><span class="built_in">move</span>(savedPos);</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><h3 id="端口兼当”防重复启动的锁”"><a href="#端口兼当”防重复启动的锁”" class="headerlink" title="端口兼当”防重复启动的锁”"></a>端口兼当”防重复启动的锁”</h3><p>如果不小心又双击了一次 ClauPet.exe 会怎样？</p><p>第二个进程会走一遍<strong>完全一样</strong>的构造流程，直到 <code>setUpServer</code> 里的 <code>listen</code> 失败：端口已经被第一个进程占着了。回到 <code>main</code>，它发现自己没在监听，就弹窗提示并退出：</p><figure class="highlight cpp"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">if</span> (pet.<span class="built_in">isListening</span>())</span><br><span class="line">&#123;</span><br><span class="line">pet.<span class="built_in">show</span>();</span><br><span class="line">&#125;</span><br><span class="line"><span class="keyword">else</span></span><br><span class="line">&#123;</span><br><span class="line">QMessageBox::<span class="built_in">warning</span>(<span class="literal">nullptr</span>, <span class="string">&quot;Error&quot;</span>, message);<span class="comment">// 端口被占用，可能已经在运行了</span></span><br><span class="line"><span class="keyword">return</span> <span class="number">1</span>;<span class="comment">// 直接返回，不进入事件循环</span></span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>程序里并没有专门写”检查是不是已经在运行”的代码，而是借用了”<strong>一个端口只能被一个程序监听</strong>“这个特性。一举两得。</p><hr><h2 id="第一幕：一条消息的旅程"><a href="#第一幕：一条消息的旅程" class="headerlink" title="第一幕：一条消息的旅程"></a>第一幕：一条消息的旅程</h2><h3 id="Claude-Code-怎么通知-ClauPet"><a href="#Claude-Code-怎么通知-ClauPet" class="headerlink" title="Claude Code 怎么通知 ClauPet"></a>Claude Code 怎么通知 ClauPet</h3><p>Claude Code 支持 <strong>hooks</strong>：在特定事件发生时，执行一个命令或者发一个 HTTP 请求。ClauPet 用的是 HTTP 方式，在 <code>~/.claude/settings.json</code> 里配置：</p><figure class="highlight json"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line"><span class="punctuation">&#123;</span></span><br><span class="line">  <span class="attr">&quot;hooks&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">    <span class="attr">&quot;UserPromptSubmit&quot;</span><span class="punctuation">:</span> <span class="punctuation">[</span></span><br><span class="line">      <span class="punctuation">&#123;</span> <span class="attr">&quot;hooks&quot;</span><span class="punctuation">:</span> <span class="punctuation">[</span> <span class="punctuation">&#123;</span> <span class="attr">&quot;type&quot;</span><span class="punctuation">:</span> <span class="string">&quot;http&quot;</span><span class="punctuation">,</span> <span class="attr">&quot;url&quot;</span><span class="punctuation">:</span> <span class="string">&quot;http://127.0.0.1:52790/&quot;</span><span class="punctuation">,</span> <span class="attr">&quot;timeout&quot;</span><span class="punctuation">:</span> <span class="number">2</span> <span class="punctuation">&#125;</span> <span class="punctuation">]</span> <span class="punctuation">&#125;</span></span><br><span class="line">    <span class="punctuation">]</span></span><br><span class="line">  <span class="punctuation">&#125;</span></span><br><span class="line"><span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure><p>（实际配置了 7 个事件：<code>UserPromptSubmit</code>、<code>PreToolUse</code>、<code>PostToolUse</code>、<code>PermissionRequest</code>、<code>Stop</code>、<code>StopFailure</code>、<code>SessionEnd</code>，格式都一样。）</p><p>当你在 Claude Code 里发了一条消息，ClauPet 就会收到这样一个请求：</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line">POST / HTTP/1.1</span><br><span class="line">Content-Type: application/json</span><br><span class="line">Content-Length: 97</span><br><span class="line"></span><br><span class="line">&#123;&quot;hook_event_name&quot;:&quot;UserPromptSubmit&quot;,&quot;session_id&quot;:&quot;aaa&quot;,&quot;session_title&quot;:&quot;修复登录 bug&quot;&#125;</span><br></pre></td></tr></table></figure><h3 id="第一站：两层-lambda"><a href="#第一站：两层-lambda" class="headerlink" title="第一站：两层 lambda"></a>第一站：两层 lambda</h3><p>请求到来时，执行的<strong>不是</strong> <code>setUpServer</code>（它在启动时就执行完了），而是它当时登记的两个 lambda：</p><figure class="highlight cpp"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br></pre></td><td class="code"><pre><span class="line"><span class="built_in">connect</span>(&amp;m_server, &amp;QTcpServer::newConnection, <span class="keyword">this</span>, [<span class="keyword">this</span>]()</span><br><span class="line">&#123;</span><br><span class="line">QTcpSocket* socket&#123; m_server.<span class="built_in">nextPendingConnection</span>() &#125;;</span><br><span class="line"><span class="built_in">connect</span>(socket, &amp;QTcpSocket::readyRead, <span class="keyword">this</span>, [socket, <span class="keyword">this</span>]()</span><br><span class="line">&#123;</span><br><span class="line">QByteArray body&#123;&#125;;</span><br><span class="line"><span class="keyword">if</span> (!tryReadBody(socket, body))</span><br><span class="line">&#123;</span><br><span class="line"><span class="keyword">return</span>;<span class="comment">// 还没收齐：等下一次 readyRead</span></span><br><span class="line">&#125;</span><br><span class="line"><span class="built_in">handleHookEvent</span>(body);</span><br><span class="line">socket-&gt;<span class="built_in">write</span>(<span class="string">&quot;HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n&quot;</span>);</span><br><span class="line">socket-&gt;<span class="built_in">disconnectFromHost</span>();</span><br><span class="line">&#125;);</span><br><span class="line"><span class="built_in">connect</span>(socket, &amp;QTcpSocket::disconnected, socket, &amp;QObject::deleteLater);</span><br><span class="line">&#125;);</span><br></pre></td></tr></table></figure><ul><li>有新连接时，第一层 lambda 执行，它为这条连接<strong>再登记</strong>“收到数据时做什么”</li><li>数据到了，第二层 lambda 执行：读数据、处理、回复</li><li>连接断开后，<code>deleteLater</code> 在安全的时机删掉这个 socket</li></ul><p><strong>为什么一定要回复？</strong> Claude Code 发完请求会等回复。hook 配置了 <code>timeout: 2</code>，不回复的话每个事件都要白等 2 秒，Claude Code 就变慢了。反过来，这个超时也是一层保险：就算 ClauPet 卡住或者没开，Claude Code 最多受影响 2 秒。</p><h3 id="第二站：tryReadBody，数据收齐了吗？"><a href="#第二站：tryReadBody，数据收齐了吗？" class="headerlink" title="第二站：tryReadBody，数据收齐了吗？"></a>第二站：tryReadBody，数据收齐了吗？</h3><p>TCP 传的是”一串字节”，<strong>不保证一次就全部送到</strong>。一个请求可能分好几次到达，<code>readyRead</code> 也就会触发好几次。所以每次都要先判断：收齐了没有？</p><figure class="highlight cpp"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br></pre></td><td class="code"><pre><span class="line"><span class="function"><span class="type">static</span> <span class="type">bool</span> <span class="title">tryReadBody</span><span class="params">(QTcpSocket* socket, QByteArray&amp; body)</span></span></span><br><span class="line"><span class="function"></span>&#123;</span><br><span class="line"><span class="type">const</span> QByteArray data&#123; socket-&gt;<span class="built_in">peek</span>(socket-&gt;<span class="built_in">bytesAvailable</span>()) &#125;;<span class="comment">// 只&quot;偷看&quot;，不取走</span></span><br><span class="line"><span class="type">const</span> qsizetype headerEnd&#123; data.<span class="built_in">indexOf</span>(<span class="string">&quot;\r\n\r\n&quot;</span>) &#125;;<span class="comment">// 请求头结束的位置</span></span><br><span class="line"><span class="keyword">if</span> (headerEnd == <span class="number">-1</span>)</span><br><span class="line">&#123;</span><br><span class="line"><span class="keyword">return</span> <span class="literal">false</span>;<span class="comment">// 请求头还没收完</span></span><br><span class="line">&#125;</span><br><span class="line"><span class="comment">// ……从请求头里找出 Content-Length（先转成小写再找）……</span></span><br><span class="line"><span class="type">const</span> qsizetype bodyStart&#123; headerEnd + <span class="number">4</span> &#125;;</span><br><span class="line"><span class="keyword">if</span> (data.<span class="built_in">size</span>() &lt; bodyStart + contentLength)</span><br><span class="line">&#123;</span><br><span class="line"><span class="keyword">return</span> <span class="literal">false</span>;<span class="comment">// 请求体还没收齐</span></span><br><span class="line">&#125;</span><br><span class="line">socket-&gt;<span class="built_in">readAll</span>();<span class="comment">// 收齐了：这时才真正取走</span></span><br><span class="line">body = data.<span class="built_in">mid</span>(bodyStart, contentLength);</span><br><span class="line"><span class="keyword">return</span> <span class="literal">true</span>;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>几个关键点：</p><ul><li><strong>判断收齐的依据</strong>：先找空行 <code>\r\n\r\n</code>（请求头到此结束），再看请求头里的 <code>Content-Length</code> 说请求体有多长。</li><li><strong><code>peek</code> 和 <code>readAll</code> 的分工</strong>：没收齐时如果用 <code>readAll</code> 把数据取走，前半截就丢了，下次永远拼不完整。所以没收齐时只”偷看”，数据留在 socket 里，等下次连同新数据一起判断；收齐了才取走。</li><li><strong>为什么要转小写</strong>：HTTP 请求头的名字不区分大小写，<code>Content-Length</code> 和 <code>content-length</code> 都是合法的。</li><li><strong><code>body</code> 是输出参数</strong>：用普通引用传进来，函数把结果写进去交给调用者；返回值只负责说”成功了没有”。</li></ul><h3 id="第三站：handleHookEvent，这条消息是什么意思"><a href="#第三站：handleHookEvent，这条消息是什么意思" class="headerlink" title="第三站：handleHookEvent，这条消息是什么意思"></a>第三站：handleHookEvent，这条消息是什么意思</h3><figure class="highlight cpp"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br></pre></td><td class="code"><pre><span class="line"><span class="function"><span class="type">void</span> <span class="title">Petwindow::handleHookEvent</span><span class="params">(<span class="type">const</span> QByteArray&amp; body)</span></span></span><br><span class="line"><span class="function"></span>&#123;</span><br><span class="line"><span class="type">const</span> QJsonObject json&#123; QJsonDocument::<span class="built_in">fromJson</span>(body).<span class="built_in">object</span>() &#125;;</span><br><span class="line"><span class="type">const</span> QString eventName&#123; json.<span class="built_in">value</span>(<span class="string">&quot;hook_event_name&quot;</span>).<span class="built_in">toString</span>() &#125;;</span><br><span class="line"><span class="type">const</span> QString sessionId&#123; json.<span class="built_in">value</span>(<span class="string">&quot;session_id&quot;</span>).<span class="built_in">toString</span>() &#125;;</span><br><span class="line"><span class="type">const</span> QString toolName&#123; json.<span class="built_in">value</span>(<span class="string">&quot;tool_name&quot;</span>).<span class="built_in">toString</span>() &#125;;</span><br><span class="line"><span class="type">const</span> QString sessionTitle&#123; json.<span class="built_in">value</span>(<span class="string">&quot;session_title&quot;</span>).<span class="built_in">toString</span>() &#125;;</span><br><span class="line"></span><br><span class="line"><span class="comment">// ① 记下标题；调用工具时更新气泡（见下文）</span></span><br><span class="line"><span class="comment">// ……</span></span><br><span class="line"></span><br><span class="line"><span class="comment">// ② 更新这个会话的状态</span></span><br><span class="line">PetState newState&#123; PetState::Idle &#125;;</span><br><span class="line"><span class="keyword">if</span> (eventName == <span class="string">&quot;SessionEnd&quot;</span>)</span><br><span class="line">&#123;</span><br><span class="line">m_sessionStates.<span class="built_in">erase</span>(sessionId);</span><br><span class="line">m_sessionTitles.<span class="built_in">erase</span>(sessionId);</span><br><span class="line">&#125;</span><br><span class="line"><span class="keyword">else</span> <span class="keyword">if</span> (<span class="built_in">stateHookEvent</span>(eventName, toolName, newState))<span class="comment">// 认识这个事件才更新</span></span><br><span class="line">&#123;</span><br><span class="line">m_sessionStates[sessionId] = newState;</span><br><span class="line"><span class="keyword">if</span> (newState == PetState::Error)</span><br><span class="line">&#123;</span><br><span class="line"><span class="built_in">scheduleErrorRecovery</span>(sessionId);<span class="comment">// 只是登记：8 秒后才执行</span></span><br><span class="line">&#125;</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="comment">// ③ 合并所有会话，刷新宠物</span></span><br><span class="line"><span class="built_in">setState</span>(<span class="built_in">combinedState</span>());</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p><code>stateHookEvent</code> 负责把 Claude Code 的事件名”翻译”成宠物的 4 种状态：</p><table><thead><tr><th>事件</th><th>状态</th></tr></thead><tbody><tr><td><code>PreToolUse</code> 且工具是 <code>AskUserQuestion</code>（Claude 要问你问题）</td><td>等你回应</td></tr><tr><td><code>UserPromptSubmit</code>、<code>PreToolUse</code>、<code>PostToolUse</code></td><td>工作中</td></tr><tr><td><code>Stop</code></td><td>空闲</td></tr><tr><td><code>PermissionRequest</code></td><td>等你回应</td></tr><tr><td><code>StopFailure</code></td><td>出错</td></tr></tbody></table><p>规则从上到下判断，<strong>越特殊的规则越要写在前面</strong>：如果”<code>PreToolUse</code> → 工作中”写在第一条，提问那条就永远轮不到了。</p><p>不认识的事件会让 <code>stateHookEvent</code> 返回 <code>false</code>，什么都不改。这一点很重要：端口是开着的，本机任何程序都能往里发东西，可能是别的事件、格式错误的 JSON，甚至根本不是 JSON。这时解析出来的全是空字符串，程序照样稳定。</p><hr><h2 id="同时开好几个会话怎么办"><a href="#同时开好几个会话怎么办" class="headerlink" title="同时开好几个会话怎么办"></a>同时开好几个会话怎么办</h2><h3 id="每个会话各存各的"><a href="#每个会话各存各的" class="headerlink" title="每个会话各存各的"></a>每个会话各存各的</h3><p>最早的版本只有一个状态变量，问题很快就出现了：会话 A 正在工作，会话 B 发来 <code>Stop</code>，宠物就变成了空闲，可 A 明明还在干活。</p><p>解决办法是<strong>按会话分开存</strong>，需要显示的时候再合并：</p><figure class="highlight cpp"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">std::map&lt;QString, PetState&gt; m_sessionStates;<span class="comment">// 会话 ID → 状态</span></span><br></pre></td></tr></table></figure><h3 id="合并：挑出最紧急的那个"><a href="#合并：挑出最紧急的那个" class="headerlink" title="合并：挑出最紧急的那个"></a>合并：挑出最紧急的那个</h3><figure class="highlight cpp"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br></pre></td><td class="code"><pre><span class="line"><span class="function"><span class="type">static</span> <span class="type">int</span> <span class="title">urgency</span><span class="params">(PetState state)</span></span></span><br><span class="line"><span class="function"></span>&#123;</span><br><span class="line"><span class="keyword">switch</span> (state)</span><br><span class="line">&#123;</span><br><span class="line"><span class="keyword">case</span> PetState::Idle:<span class="keyword">return</span> <span class="number">0</span>;</span><br><span class="line"><span class="keyword">case</span> PetState::Working:<span class="keyword">return</span> <span class="number">1</span>;</span><br><span class="line"><span class="keyword">case</span> PetState::NeedsInput:<span class="keyword">return</span> <span class="number">2</span>;</span><br><span class="line"><span class="keyword">case</span> PetState::Error:<span class="keyword">return</span> <span class="number">3</span>;</span><br><span class="line">&#125;</span><br><span class="line"><span class="keyword">return</span> <span class="number">0</span>;</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="function">PetState <span class="title">Petwindow::combinedState</span><span class="params">()</span> <span class="type">const</span></span></span><br><span class="line"><span class="function"></span>&#123;</span><br><span class="line"><span class="keyword">if</span> (m_sessionStates.<span class="built_in">empty</span>())</span><br><span class="line">&#123;</span><br><span class="line"><span class="keyword">return</span> PetState::Idle;<span class="comment">// 必须先判断：空 map 上 max_element 返回 end()，不能解引用</span></span><br><span class="line">&#125;</span><br><span class="line"><span class="keyword">return</span> std::<span class="built_in">max_element</span>(m_sessionStates.<span class="built_in">begin</span>(), m_sessionStates.<span class="built_in">end</span>(),</span><br><span class="line">[](<span class="type">const</span> <span class="keyword">auto</span>&amp; a, <span class="type">const</span> <span class="keyword">auto</span>&amp; b)</span><br><span class="line">&#123;</span><br><span class="line"><span class="keyword">return</span> <span class="built_in">urgency</span>(a.second) &lt; <span class="built_in">urgency</span>(b.second);</span><br><span class="line">&#125;)-&gt;second;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><ul><li><strong>为什么单独写 <code>urgency()</code></strong>：<code>enum class</code> 不能直接当数字比较；就算能比，枚举的声明顺序也不代表紧急程度。单独写一个函数，优先级就是一个明确的决定。</li><li><strong>为什么先判断空</strong>：map 为空时，<code>max_element</code> 返回 <code>end()</code>，对它解引用是<strong>未定义行为</strong>。</li><li><strong>为什么不能用 <code>[]</code> 查询</strong>：<code>combinedState</code> 是常函数，而 <code>map</code> 的 <code>[]</code> 找不到键时会<strong>插入</strong>新元素，属于修改，编译不过。</li></ul><h3 id="和-find-的坑"><a href="#和-find-的坑" class="headerlink" title="[] 和 find 的坑"></a><code>[]</code> 和 <code>find</code> 的坑</h3><p>上面提到了 <code>[]</code> 会插入元素。这是这个项目里我踩得最多的一个点，值得单独说。</p><p><code>std::map</code> 的 <code>[]</code> 找不到键时，会插入一个新元素，值是默认值。这<strong>不是</strong>未定义行为，是标准规定好的，程序不会崩，只是结果不对，所以反而更难发现。</p><p>项目里有两处必须用 <code>find</code> 而不是 <code>[]</code>：</p><p><strong>1. 气泡查标题</strong></p><p>会话标题只在 <code>UserPromptSubmit</code>（你发消息时）才会带过来，而用到标题的是之后的 <code>PreToolUse</code>（Claude 调用工具时）。所以要先存起来，用的时候再查：</p><figure class="highlight cpp"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">if</span> (!sessionTitle.<span class="built_in">isEmpty</span>())</span><br><span class="line">&#123;</span><br><span class="line">m_sessionTitles[sessionId] = sessionTitle;<span class="comment">// 本来就要插入或更新，用 [] 正合适</span></span><br><span class="line">&#125;</span><br><span class="line"><span class="keyword">if</span> (eventName == <span class="string">&quot;PreToolUse&quot;</span>)</span><br><span class="line">&#123;</span><br><span class="line"><span class="keyword">auto</span> it = m_sessionTitles.<span class="built_in">find</span>(sessionId);<span class="comment">// 只查不插</span></span><br><span class="line"><span class="keyword">if</span> (it != m_sessionTitles.<span class="built_in">end</span>())</span><br><span class="line">&#123;</span><br><span class="line">m_bubbleText = it-&gt;second + <span class="string">&quot;：&quot;</span> + toolName;<span class="comment">// 例如&quot;修复登录 bug：Read&quot;</span></span><br><span class="line">&#125;</span><br><span class="line"><span class="keyword">else</span></span><br><span class="line">&#123;</span><br><span class="line">m_bubbleText = toolName;<span class="comment">// 不知道标题（比如宠物是中途才启动的）</span></span><br><span class="line">&#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>如果这里用 <code>[]</code>，宠物中途启动时，气泡会显示成”：Read”，map 里还会多一条空标题的记录。</p><p><strong>2. 出错恢复时查会话</strong>（见下一节）</p><hr><h2 id="出错后的-8-秒自动恢复"><a href="#出错后的-8-秒自动恢复" class="headerlink" title="出错后的 8 秒自动恢复"></a>出错后的 8 秒自动恢复</h2><p>会话出错（<code>StopFailure</code>）之后，Claude Code 不会再发后续事件，<code>Stop</code> 不会来。不处理的话，宠物会一直显示出错。所以出错时安排一个定时器，过 8 秒把这个会话恢复为空闲：</p><figure class="highlight cpp"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br></pre></td><td class="code"><pre><span class="line"><span class="function"><span class="type">void</span> <span class="title">Petwindow::scheduleErrorRecovery</span><span class="params">(<span class="type">const</span> QString&amp; sessionId)</span></span></span><br><span class="line"><span class="function"></span>&#123;</span><br><span class="line">QTimer::<span class="built_in">singleShot</span>(kErrorRecoverMs, <span class="keyword">this</span>, [<span class="keyword">this</span>, sessionId]()</span><br><span class="line">&#123;</span><br><span class="line"><span class="keyword">auto</span> it = m_sessionStates.<span class="built_in">find</span>(sessionId);</span><br><span class="line"><span class="keyword">if</span> (it != m_sessionStates.<span class="built_in">end</span>() &amp;&amp; it-&gt;second == PetState::Error)</span><br><span class="line">&#123;</span><br><span class="line">m_sessionStates[sessionId] = PetState::Idle;</span><br><span class="line">&#125;</span><br><span class="line"><span class="built_in">setState</span>(<span class="built_in">combinedState</span>());</span><br><span class="line">&#125;);</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>这又是一次”登记”：<code>singleShot</code> 立刻返回，lambda 要 8 秒后才由事件循环执行。所以出错时，宠物<strong>马上</strong>显示出错，靠的是 <code>handleHookEvent</code> 最后那句 <code>setState(combinedState())</code>，而不是这里的 <code>setState</code>。</p><p>8 秒里可能发生很多事，<code>if</code> 里的两个条件正好防住两种变化：</p><ul><li><strong><code>it != end()</code>：会话已经结束了。</strong> 如果用 <code>[]</code>，会把已经 <code>erase</code> 掉的会话重新插回来。它的 <code>SessionEnd</code> 已经来过了，再也没人会删它，成了一个永远赶不走的”幽灵会话”。</li><li><strong><code>it-&gt;second == Error</code>：会话又开始干活了。</strong> 不检查的话，会把正在工作的会话误改成空闲。</li></ul><p>还有一个 C++ 细节：<strong><code>sessionId</code> 必须按值捕获</strong>。8 秒后 lambda 执行时，<code>handleHookEvent</code> 早就返回了，它的局部变量 <code>sessionId</code> 已经销毁。按引用捕获会变成悬空引用；按值捕获则把字符串复制一份存进 lambda。注意，参数虽然是引用（<code>const QString&amp;</code>），按值捕获复制的是引用<strong>所指向的对象</strong>。</p><hr><h2 id="改状态和画画面，是两件事"><a href="#改状态和画画面，是两件事" class="headerlink" title="改状态和画画面，是两件事"></a>改状态和画画面，是两件事</h2><h3 id="setState：改状态的唯一入口"><a href="#setState：改状态的唯一入口" class="headerlink" title="setState：改状态的唯一入口"></a>setState：改状态的唯一入口</h3><figure class="highlight cpp"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><span class="line"><span class="function"><span class="type">void</span> <span class="title">Petwindow::setState</span><span class="params">(PetState state)</span></span></span><br><span class="line"><span class="function"></span>&#123;</span><br><span class="line">m_state = state;</span><br><span class="line"><span class="keyword">if</span> (m_state == PetState::Idle)</span><br><span class="line">&#123;</span><br><span class="line">m_bubbleText.<span class="built_in">clear</span>();<span class="comment">// 所有会话都空闲了：清空气泡</span></span><br><span class="line">&#125;</span><br><span class="line"><span class="built_in">update</span>();<span class="comment">// 预约重画</span></span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>不管是 hook 事件、8 秒恢复，还是按数字键测试，所有改状态的地方都经过 <code>setState</code>。”状态变了就必须一起做的事”（清空气泡、请求重画）放在这里，就不会被哪个地方漏掉。</p><h3 id="update-：只是”预约”"><a href="#update-：只是”预约”" class="headerlink" title="update()：只是”预约”"></a>update()：只是”预约”</h3><p><code>update()</code> 并不会马上重画，它只是告诉 Qt”稍后需要重画”。真正的 <code>paintEvent</code> 要等当前的代码全部执行完、回到事件循环之后才发生。</p><p>拿第一幕的请求来说，完整的顺序是：</p><ol><li><code>handleHookEvent</code> → <code>setState</code> → <code>update()</code>：<strong>预约</strong>重画</li><li>回到 <code>readyRead</code> 的 lambda：回复 200，断开连接</li><li>lambda 执行完，回到事件循环</li><li>Qt 调用 <code>paintEvent</code>：宠物这时才真正换成”工作中”</li></ol><p>这也解释了为什么 <code>handleHookEvent</code> 里改完气泡文字不用单独 <code>update()</code>：后面的 <code>setState</code> 会预约，而真正画的时候，读到的已经是最终的文字。</p><h3 id="paintEvent：只读，不改"><a href="#paintEvent：只读，不改" class="headerlink" title="paintEvent：只读，不改"></a>paintEvent：只读，不改</h3><p><code>paintEvent</code> 是整个程序的终点，它根据这几样东西决定画什么：</p><ul><li><code>m_state</code>：画哪张表情</li><li><code>m_dragging</code>：是否正在被拖动（拖动时画”被拎起来”的图，不浮动，不画气泡）</li><li><code>m_bubbleText</code>：气泡里写什么（空字符串就不画）</li><li>闹钟是否在响：要不要浮动</li><li>秒表的时间：浮动到了哪个位置</li></ul><p>它<strong>只读不改</strong>。这些东西都由别的函数修改，改完负责请求重画：</p><table><thead><tr><th><code>paintEvent</code> 读的东西</th><th>谁改它</th><th>改完怎么请求重画</th></tr></thead><tbody><tr><td><code>m_state</code></td><td><code>setState</code>（来自 hook 事件、8 秒恢复、数字键）</td><td><code>setState</code> 里 <code>update()</code></td></tr><tr><td><code>m_bubbleText</code></td><td><code>handleHookEvent</code> 设置，<code>setState</code> 清空</td><td>最后都经过 <code>setState</code></td></tr><tr><td><code>m_dragging</code></td><td>鼠标移动时设为 true，松开时设为 false</td><td>各自 <code>update()</code></td></tr><tr><td>闹钟是否在响</td><td><code>enterEvent</code> 启动，<code>leaveEvent</code> &#x2F; <code>hideEvent</code> 停止</td><td>闹钟响时自动 <code>update()</code></td></tr><tr><td>秒表的时间</td><td><code>enterEvent</code> 归零，之后自己在走</td><td>靠闹钟每 33 毫秒请求一次</td></tr></tbody></table><p>一句话总结：<strong>谁改了画面要用的东西，谁就负责调用 <code>update()</code>；<code>paintEvent</code> 只管按当前的值画。</strong></p><hr><h2 id="第二幕：和宠物互动"><a href="#第二幕：和宠物互动" class="headerlink" title="第二幕：和宠物互动"></a>第二幕：和宠物互动</h2><h3 id="悬停时浮动：一个”闹钟”，一个”秒表”"><a href="#悬停时浮动：一个”闹钟”，一个”秒表”" class="headerlink" title="悬停时浮动：一个”闹钟”，一个”秒表”"></a>悬停时浮动：一个”闹钟”，一个”秒表”</h3><p>鼠标移到宠物身上，宠物开始上下浮动。实现它用了两个名字很像、作用完全不同的工具：</p><ul><li><strong>闹钟</strong> <code>QTimer m_animTimer</code>：每 33 毫秒响一次，每次请求重画。负责”<strong>什么时候</strong>画”</li><li><strong>秒表</strong> <code>QElapsedTimer m_animClock</code>：记录开始浮动后过了多久。负责”<strong>画在哪</strong>“</li></ul><figure class="highlight cpp"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br></pre></td><td class="code"><pre><span class="line"><span class="function"><span class="type">void</span> <span class="title">Petwindow::enterEvent</span><span class="params">(QEnterEvent*)</span></span></span><br><span class="line"><span class="function"></span>&#123;</span><br><span class="line">m_animClock.<span class="built_in">restart</span>();<span class="comment">// 秒表归零：sin(0) = 0，从中间开始，不会一上来就跳</span></span><br><span class="line">m_animTimer.<span class="built_in">start</span>();<span class="comment">// 闹钟开始响</span></span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="function"><span class="type">void</span> <span class="title">Petwindow::leaveEvent</span><span class="params">(QEvent*)</span></span></span><br><span class="line"><span class="function"></span>&#123;</span><br><span class="line">m_animTimer.<span class="built_in">stop</span>();</span><br><span class="line"><span class="built_in">update</span>();<span class="comment">// 最后重画一次：闹钟停了，位移为 0，回到中间</span></span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p><code>paintEvent</code> 里按秒表算位移：</p><figure class="highlight cpp"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line">qreal offset&#123;&#125;;</span><br><span class="line"><span class="keyword">if</span> (m_animTimer.<span class="built_in">isActive</span>())</span><br><span class="line">&#123;</span><br><span class="line"><span class="type">const</span> qreal timeElapsed&#123; m_animClock.<span class="built_in">elapsed</span>() / <span class="number">1000.0</span> &#125;;<span class="comment">// 必须除以 1000.0，整数除法会丢掉小数</span></span><br><span class="line">offset = kFloatAmplitude * std::<span class="built_in">sin</span>(<span class="number">2</span> * kPi * timeElapsed / kFloatPeriodSec);</span><br><span class="line">&#125;</span><br><span class="line">painter.<span class="built_in">drawPixmap</span>(<span class="built_in">QPointF</span>(<span class="number">0</span>, kFloatAmplitude + offset + kBubbleHeight), *pixmap);</span><br></pre></td></tr></table></figure><p>几个设计点：</p><ul><li><strong>为什么需要闹钟</strong>：浮动时没有任何变量被修改，变化的是”时间”本身。Qt 不知道时间会影响画面，所以只能由定时器不停地请求重画。</li><li><strong>为什么按”经过的时间”算，而不是”闹钟响了几次”</strong>：电脑卡顿时定时器会迟到，按次数算，动画就会变慢；按实际时间算，动画速度始终一致。</li><li><strong>为什么用 <code>QPointF</code></strong>：小数坐标可以画在”半个像素”的位置，浮动才平滑；<code>QPoint</code> 会取整，一格一格地跳。</li><li><strong>秒表为什么启动时就要 <code>start()</code></strong>：对没启动过的 <code>QElapsedTimer</code> 调用 <code>elapsed()</code> 是未定义行为。</li></ul><h3 id="拖动还是单击？"><a href="#拖动还是单击？" class="headerlink" title="拖动还是单击？"></a>拖动还是单击？</h3><p>拖动和单击都是”按下、再松开”，程序怎么区分？</p><figure class="highlight cpp"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br></pre></td><td class="code"><pre><span class="line"><span class="function"><span class="type">void</span> <span class="title">Petwindow::mouseMoveEvent</span><span class="params">(QMouseEvent* event)</span></span></span><br><span class="line"><span class="function"></span>&#123;</span><br><span class="line"><span class="keyword">if</span> (event-&gt;<span class="built_in">buttons</span>() &amp; Qt::LeftButton)</span><br><span class="line">&#123;</span><br><span class="line"><span class="comment">// 离按下的位置足够远（系统的拖动阈值），才算开始拖动</span></span><br><span class="line"><span class="keyword">if</span> (!m_dragging &amp;&amp; (m_pressGlobalPos - event-&gt;<span class="built_in">globalPosition</span>().<span class="built_in">toPoint</span>()).<span class="built_in">manhattanLength</span>()</span><br><span class="line">&gt;= QApplication::<span class="built_in">startDragDistance</span>())</span><br><span class="line">&#123;</span><br><span class="line">m_dragging = <span class="literal">true</span>;</span><br><span class="line"><span class="built_in">update</span>();</span><br><span class="line">&#125;</span><br><span class="line"><span class="built_in">move</span>(event-&gt;<span class="built_in">globalPosition</span>().<span class="built_in">toPoint</span>() - m_dragOffset);</span><br><span class="line">&#125;</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="function"><span class="type">void</span> <span class="title">Petwindow::mouseReleaseEvent</span><span class="params">(QMouseEvent* event)</span></span></span><br><span class="line"><span class="function"></span>&#123;</span><br><span class="line"><span class="keyword">if</span> (event-&gt;<span class="built_in">button</span>() == Qt::LeftButton)</span><br><span class="line">&#123;</span><br><span class="line"><span class="keyword">if</span> (m_dragging)<span class="comment">// 拖动结束</span></span><br><span class="line">&#123;</span><br><span class="line">m_dragging = <span class="literal">false</span>;</span><br><span class="line"><span class="built_in">update</span>();</span><br><span class="line">QSettings settings&#123; kSettingsName, kSettingsName &#125;;</span><br><span class="line">settings.<span class="built_in">setValue</span>(<span class="string">&quot;pos&quot;</span>, <span class="built_in">pos</span>());<span class="comment">// 保存位置，下次启动时恢复</span></span><br><span class="line">&#125;</span><br><span class="line"><span class="keyword">else</span><span class="comment">// 单击</span></span><br><span class="line">&#123;</span><br><span class="line"><span class="built_in">activateClaudeWindow</span>();</span><br><span class="line">&#125;</span><br><span class="line">&#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><ul><li><strong>按下时不换图</strong>：这时还不知道是单击还是拖动，等鼠标真的移动了一段距离再说。</li><li><strong>“是不是拖动”只在一个地方判断</strong>：<code>mouseMoveEvent</code> 判断并记进 <code>m_dragging</code>，<code>mouseReleaseEvent</code> 直接看结果。以后要调整规则，只改一处。</li><li><strong>用屏幕坐标算距离</strong>：拖动时窗口本身在移动，窗口坐标的参照点会跟着变。</li><li><strong><code>mouseMoveEvent</code> 不直接换图</strong>：它只把 <code>m_dragging</code> 设为 true 并预约重画，换图是 <code>paintEvent</code> 的事。又是”改状态”和”画画面”分开。</li><li><strong>松开时才保存位置</strong>：拖一次只写一次注册表；如果在移动时保存，拖一下就要写几十上百次。单纯单击也不会写。</li></ul><h3 id="单击：把-Claude-切到前面"><a href="#单击：把-Claude-切到前面" class="headerlink" title="单击：把 Claude 切到前面"></a>单击：把 Claude 切到前面</h3><p>单击宠物会把 Claude 桌面应用的窗口切到最前面。这里用的是 Windows API，大部分是固定写法，只有两点值得一说：</p><figure class="highlight cpp"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">HWND hwnd&#123; <span class="built_in">FindWindowW</span>(<span class="string">L&quot;Chrome_WidgetWin_1&quot;</span>, <span class="string">L&quot;Claude&quot;</span>) &#125;;</span><br></pre></td></tr></table></figure><ul><li><strong>按”类名 + 标题”找窗口</strong>：Claude 桌面应用是用 Electron 做的，窗口类名和 Chrome 浏览器一样，都是 <code>Chrome_WidgetWin_1</code>。只按类名找，可能找到的是 Chrome 或者 VS Code，所以要再加上标题。（这也意味着，如果哪天 Claude 改了窗口标题，这个功能就会失效。）</li><li><strong>为什么只在单击时切换</strong>：Windows 不允许程序随便把别的窗口切到前台（防止弹窗抢焦点）。刚被点击时，ClauPet 自己在前台，这时才被允许把 Claude 切过去。</li></ul><hr><h2 id="第三幕：结束"><a href="#第三幕：结束" class="headerlink" title="第三幕：结束"></a>第三幕：结束</h2><h3 id="会话结束"><a href="#会话结束" class="headerlink" title="会话结束"></a>会话结束</h3><p>收到 <code>SessionEnd</code>，就把这个会话从两个 map 里都删掉。全部会话都结束后，map 为空，<code>combinedState</code> 返回空闲，<code>setState</code> 顺带清空气泡。</p><h3 id="隐藏"><a href="#隐藏" class="headerlink" title="隐藏"></a>隐藏</h3><p>右键菜单可以隐藏宠物。隐藏之后程序照样接收事件，只是看不见。<code>hideEvent</code> 里会停掉闹钟：</p><figure class="highlight cpp"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line"><span class="function"><span class="type">void</span> <span class="title">Petwindow::hideEvent</span><span class="params">(QHideEvent*)</span></span></span><br><span class="line"><span class="function"></span>&#123;</span><br><span class="line">m_animTimer.<span class="built_in">stop</span>();</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>为什么不靠 <code>leaveEvent</code>？点「隐藏」时鼠标在菜单上，窗口直接消失了，<code>leaveEvent</code> <strong>不一定会来</strong>。<code>hideEvent</code> 是一道保险：不管怎么隐藏的，闹钟都会停，不会在看不见的时候白白重画。</p><h3 id="退出，以及一个析构顺序的细节"><a href="#退出，以及一个析构顺序的细节" class="headerlink" title="退出，以及一个析构顺序的细节"></a>退出，以及一个析构顺序的细节</h3><p>点击托盘菜单的「退出」：</p><ol><li>执行 <code>QApplication::quit()</code>，通知事件循环结束</li><li><code>app.exec()</code> 返回</li><li><code>main</code> 返回，局部变量按 C++ 的规则析构：后构造的先析构，先 <code>pet</code>，后 <code>app</code></li></ol><p>析构是 <strong>C++ 的作用域规则</strong>做的，不是 Qt。</p><p><code>pet</code> 析构时，它的成员也会按<strong>声明的相反顺序</strong>析构。这里有一个容易忽略的细节：</p><figure class="highlight cpp"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// 注意顺序：成员按声明顺序构造、按相反顺序析构。</span></span><br><span class="line"><span class="comment">// m_tray 记着 m_menu 的地址，所以 m_menu 必须先声明（后析构）</span></span><br><span class="line">QMenu m_menu;</span><br><span class="line">QSystemTrayIcon m_tray;</span><br></pre></td></tr></table></figure><p>托盘图标通过 <code>setContextMenu(&amp;m_menu)</code> 记住了菜单的地址。<code>m_menu</code> 声明在前，所以后析构；托盘析构的时候，菜单还在。如果顺序反过来，托盘析构时手里拿的就是一个悬空指针。</p><hr><h2 id="已知的限制"><a href="#已知的限制" class="headerlink" title="已知的限制"></a>已知的限制</h2><p>写这篇文章的过程中，我也发现了几个现在的设计没有处理好的地方：</p><ul><li><strong>收不到 <code>SessionEnd</code></strong>：如果 Claude Code 在会话还在工作时被强制关掉（崩溃、杀进程），<code>SessionEnd</code> 就没机会发出来。这个会话会一直留在 map 里，宠物一直显示工作中，只能重启 ClauPet。</li><li><strong>气泡显示的不一定是”当前状态”所属的会话</strong>：气泡显示的是”最近一次调用工具的是谁”。比如 A 在读文件、B 在等授权，宠物显示 B 的”等你回应”，气泡却是 A 的”修复登录 bug：Read”。</li><li><strong>依赖 Claude 的窗口标题</strong>：单击切换窗口靠的是标题 <code>Claude</code>，标题变了就会失效。</li></ul><hr><h2 id="写在最后"><a href="#写在最后" class="headerlink" title="写在最后"></a>写在最后</h2><p>回头看，这个项目里真正重要的设计其实就几条：</p><ol><li><strong>登记和执行是两回事</strong>：构造函数只登记，事件循环负责执行。<code>connect</code>、<code>singleShot</code>、<code>update()</code> 都是”现在登记，以后发生”。</li><li><strong>改状态和画画面分开</strong>：谁改了状态谁就 <code>update()</code>，<code>paintEvent</code> 只读不改。</li><li><strong>改状态只有一个入口</strong>：所有状态变化都经过 <code>setState</code>，该一起做的事不会漏。</li><li><strong>按会话分开存，显示时再合并</strong>：多个会话互不覆盖。</li><li><strong><code>map</code> 只想查询时用 <code>find</code></strong>：<code>[]</code> 会悄悄插入元素，造成”幽灵会话”。</li></ol><p>这些东西单独看都不难，但只有把一条消息从头到尾走一遍，才能看清它们是怎么配合起来的。</p>]]>
    </content>
    <id>https://wwhhyyy.com/2026/11/02/ClauPet%E6%9E%B6%E6%9E%84%E5%8D%9A%E5%AE%A2/</id>
    <link href="https://wwhhyyy.com/2026/11/02/ClauPet%E6%9E%B6%E6%9E%84%E5%8D%9A%E5%AE%A2/"/>
    <published>2026-11-02T14:24:12.000Z</published>
    <summary>
      <![CDATA[<blockquote>
<p>ClauPet 是一只住在 Windows 桌面上的小宠物，它会根据 Claude Code 正在做什么切换表情：你发消息时它开始工作，Claude 等你授权时它举手，出错了它会着急。</p>
<p>项目地址：<a href="https://gi]]>
    </summary>
    <title>拆解我的桌宠 ClauPet：一条消息是怎么让宠物换表情的</title>
    <updated>2026-11-02T15:00:28.920Z</updated>
  </entry>
</feed>
