mirror of
https://github.com/hanzoai/dev.git
synced 2026-10-25 03:32:32 +00:00
fix(tui/appearance): implement anchored scrolling (top→center→bottom) and ensure selection visible on entry\n\n- Render-time windowing: top until reaching middle, center while scrolling, bottom shows end\n- Make initial entry show selected row at top one time (then anchor)\n- Remove stateful offsets; keep simple Up/Down selection updates\n- Keep selector colored for dark themes\n\nBuild: ./build-fast.sh clean (no warnings)
This commit is contained in:
@@ -63,6 +63,22 @@ jobs:
|
||||
core.setOutput('issue_body', issue.body || '');
|
||||
core.setOutput('branch', `issue-${issue.number}`);
|
||||
|
||||
- name: Resolve codex slug
|
||||
id: slug
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const owner = context.repo.owner; const repo = context.repo.repo;
|
||||
const issue_number = Number(`${{ steps.meta.outputs.issue_number }}`);
|
||||
// Prefer repository_dispatch payload
|
||||
let slug = (context.eventName === 'repository_dispatch' && context.payload.client_payload && context.payload.client_payload.slug) ? String(context.payload.client_payload.slug).toLowerCase() : '';
|
||||
const markerRe = /<!--\s*codex-id:\s*([a-z0-9-]{3,})\s*-->/i;
|
||||
if (!slug) {
|
||||
const comments = await github.paginate(github.rest.issues.listComments, { owner, repo, issue_number, per_page: 100 });
|
||||
for (const c of comments) { const m = (c.body || '').match(markerRe); if (m) { slug = m[1].toLowerCase(); break; } }
|
||||
}
|
||||
core.setOutput('slug', slug || '');
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
@@ -256,7 +272,9 @@ jobs:
|
||||
const base = process.env.DEFAULT_BRANCH || 'main';
|
||||
const title = readOrDefault('.github/auto/PR_TITLE.txt', `Auto PR: ${{ steps.meta.outputs.issue_title }}`);
|
||||
const bodyBase = readOrDefault('.github/auto/PR_BODY.md', '');
|
||||
const footer = ['','---',`Auto-generated for issue #${{ steps.meta.outputs.issue_number }} by a workflow.`,`Author: @${context.actor}`].join('\n');
|
||||
const slug = `${{ steps.slug.outputs.slug }}`;
|
||||
const marker = slug ? `<!-- codex-id: ${slug} -->` : '';
|
||||
const footer = ['','---',`Auto-generated for issue #${{ steps.meta.outputs.issue_number }} by a workflow.`,`Author: @${context.actor}`, marker].filter(Boolean).join('\n');
|
||||
const body = (bodyBase ? `${bodyBase}\n${footer}` : footer);
|
||||
// Attempt create; if exists, reuse
|
||||
const params = { owner, repo, title, head, base, body };
|
||||
@@ -273,4 +291,3 @@ jobs:
|
||||
script: |
|
||||
const owner = context.repo.owner; const repo = context.repo.repo; const issue_number = Number(process.env.ISSUE_NUMBER);
|
||||
try { await github.rest.issues.addAssignees({ owner, repo, issue_number, assignees: ['just-every-code'] }); } catch {}
|
||||
|
||||
|
||||
@@ -110,8 +110,32 @@ jobs:
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
const owner = context.repo.owner; const repo = context.repo.repo;
|
||||
const pr = process.env.PR_NUMBER;
|
||||
const tag = `preview-pr-${pr}`;
|
||||
const pr = Number(process.env.PR_NUMBER);
|
||||
// resolve slug
|
||||
const { data: pull } = await github.rest.pulls.get({ owner, repo, pull_number: pr });
|
||||
const marker = /<!--\s*codex-id:\s*([a-z0-9-]{3,})\s*-->/i;
|
||||
let slug = '';
|
||||
const m = (pull.body || '').match(marker);
|
||||
if (m) slug = m[1].toLowerCase();
|
||||
if (!slug) {
|
||||
const b = pull.head.ref || '';
|
||||
const im = b.match(/^issue-(\d+)$/); let issue_number = 0; if (im) issue_number = Number(im[1]);
|
||||
if (issue_number) {
|
||||
const comments = await github.paginate(github.rest.issues.listComments, { owner, repo, issue_number, per_page: 100 });
|
||||
for (const c of comments) { const mm = (c.body || '').match(marker); if (mm) { slug = mm[1].toLowerCase(); break; } }
|
||||
}
|
||||
}
|
||||
if (!slug) throw new Error('Missing codex-id marker on PR/issue.');
|
||||
// resolve latest tag for slug
|
||||
const baseTag = `preview-${slug}`;
|
||||
const rels = await github.paginate(github.rest.repos.listReleases, { owner, repo, per_page: 100 });
|
||||
let tag = baseTag; let maxN = 0; let hasBase = false;
|
||||
for (const r of rels) {
|
||||
const t = r.tag_name || '';
|
||||
if (t === baseTag) { hasBase = true; maxN = Math.max(maxN, 1); tag = baseTag; }
|
||||
const mm = t.match(new RegExp(`^${baseTag}-(\\d+)$`));
|
||||
if (mm) { const n = parseInt(mm[1], 10); if (n > maxN) { maxN = n; tag = t; } }
|
||||
}
|
||||
const releaseBase = `https://github.com/${owner}/${repo}/releases/download/${tag}`;
|
||||
const dl = [
|
||||
`- Linux x86_64 (.zst): ${releaseBase}/code-x86_64-unknown-linux-musl.zst`,
|
||||
@@ -139,7 +163,7 @@ jobs:
|
||||
}
|
||||
lines.push('## Preview command');
|
||||
lines.push('```bash');
|
||||
lines.push(`code preview ${pr}`);
|
||||
lines.push(`code preview ${slug}`);
|
||||
lines.push('```');
|
||||
lines.push('');
|
||||
lines.push('## Direct downloads');
|
||||
@@ -235,14 +259,36 @@ jobs:
|
||||
let body = '';
|
||||
try { body = fs.readFileSync('.github/auto/ISSUE_COMMENT.md','utf8').trim(); } catch {}
|
||||
if (!body) {
|
||||
const tag = `preview-pr-${pr}`;
|
||||
// derive slug + latest tag
|
||||
const { data: pull } = await github.rest.pulls.get({ owner, repo, pull_number: pr });
|
||||
const marker = /<!--\s*codex-id:\s*([a-z0-9-]{3,})\s*-->/i;
|
||||
let slug = '';
|
||||
const m = (pull.body || '').match(marker);
|
||||
if (m) slug = m[1].toLowerCase();
|
||||
if (!slug) {
|
||||
const b = pull.head.ref || '';
|
||||
const im = b.match(/^issue-(\d+)$/); let issue_number2 = 0; if (im) issue_number2 = Number(im[1]);
|
||||
if (issue_number2) {
|
||||
const comments = await github.paginate(github.rest.issues.listComments, { owner, repo, issue_number: issue_number2, per_page: 100 });
|
||||
for (const c of comments) { const mm = (c.body || '').match(marker); if (mm) { slug = mm[1].toLowerCase(); break; } }
|
||||
}
|
||||
}
|
||||
const baseTag = `preview-${slug}`;
|
||||
const rels = await github.paginate(github.rest.repos.listReleases, { owner, repo, per_page: 100 });
|
||||
let tag = baseTag; let maxN = 0; let hasBase = false;
|
||||
for (const r of rels) {
|
||||
const t = r.tag_name || '';
|
||||
if (t === baseTag) { hasBase = true; maxN = Math.max(maxN, 1); tag = baseTag; }
|
||||
const mm = t.match(new RegExp(`^${baseTag}-(\\d+)$`));
|
||||
if (mm) { const n = parseInt(mm[1], 10); if (n > maxN) { maxN = n; tag = t; } }
|
||||
}
|
||||
const base = `https://github.com/${owner}/${repo}/releases/download/${tag}`;
|
||||
body = [
|
||||
'### Preview Build',
|
||||
'',
|
||||
'You can run this right now! In your terminal just use:',
|
||||
'```bash',
|
||||
`code preview ${pr}`,
|
||||
`code preview ${slug}`,
|
||||
'```',
|
||||
'This will run the version of Code we\'ve created for this issue.',
|
||||
'',
|
||||
|
||||
@@ -538,7 +538,8 @@ jobs:
|
||||
script: |
|
||||
const owner = context.repo.owner; const repo = context.repo.repo;
|
||||
const issue_number = Number(process.env.ISSUE_NUMBER);
|
||||
await github.rest.repos.createDispatchEvent({ owner, repo, event_type: 'issue-code', client_payload: { issue_number } });
|
||||
const slug = `${{ steps.slug.outputs.slug }}`;
|
||||
await github.rest.repos.createDispatchEvent({ owner, repo, event_type: 'issue-code', client_payload: { issue_number, slug } });
|
||||
core.notice(`Queued issue-code for #${issue_number}`);
|
||||
|
||||
- name: Acknowledge queueing
|
||||
|
||||
@@ -185,6 +185,41 @@ jobs:
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.head.ref != 'upstream-merge'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Resolve slug and next tag
|
||||
id: slug
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const owner = context.repo.owner; const repo = context.repo.repo; const pr = context.payload.pull_request.number;
|
||||
const { data: pull } = await github.rest.pulls.get({ owner, repo, pull_number: pr });
|
||||
const marker = /<!--\s*codex-id:\s*([a-z0-9-]{3,})\s*-->/i;
|
||||
let slug = '';
|
||||
const m = (pull.body || '').match(marker);
|
||||
if (m) slug = m[1].toLowerCase();
|
||||
if (!slug) {
|
||||
// fallback: try derive from head branch issue-<n>
|
||||
const b = pull.head.ref || '';
|
||||
const im = b.match(/^issue-(\d+)$/); let issue_number = 0; if (im) issue_number = Number(im[1]);
|
||||
if (issue_number) {
|
||||
const comments = await github.paginate(github.rest.issues.listComments, { owner, repo, issue_number, per_page: 100 });
|
||||
for (const c of comments) { const mm = (c.body || '').match(marker); if (mm) { slug = mm[1].toLowerCase(); break; } }
|
||||
}
|
||||
if (!slug) throw new Error('Missing codex-id marker on PR/issue.');
|
||||
}
|
||||
// Determine next tag: preview-<slug> or preview-<slug>-N
|
||||
const base = `preview-${slug}`;
|
||||
const rels = await github.paginate(github.rest.repos.listReleases, { owner, repo, per_page: 100 });
|
||||
let maxN = 0; let hasBase = false;
|
||||
for (const r of rels) {
|
||||
const t = r.tag_name || '';
|
||||
if (t === base) { hasBase = true; maxN = Math.max(maxN, 1); }
|
||||
const mm = t.match(new RegExp(`^${base}-(\\d+)$`));
|
||||
if (mm) maxN = Math.max(maxN, parseInt(mm[1], 10));
|
||||
}
|
||||
const next = hasBase || maxN > 0 ? `${base}-${maxN+1}` : base;
|
||||
core.setOutput('slug', slug);
|
||||
core.setOutput('tag', next);
|
||||
core.setOutput('title', `Preview for ${slug}`);
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
@@ -193,8 +228,8 @@ jobs:
|
||||
- name: Create or update prerelease with assets
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TAG: preview-pr-${{ github.event.pull_request.number }}
|
||||
TITLE: Preview for PR #${{ github.event.pull_request.number }}
|
||||
TAG: ${{ steps.slug.outputs.tag }}
|
||||
TITLE: ${{ steps.slug.outputs.title }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -223,6 +258,37 @@ jobs:
|
||||
needs: [build, release]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Resolve slug and latest tag
|
||||
id: slug
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const owner = context.repo.owner; const repo = context.repo.repo; const pr = context.payload.pull_request.number;
|
||||
const { data: pull } = await github.rest.pulls.get({ owner, repo, pull_number: pr });
|
||||
const marker = /<!--\s*codex-id:\s*([a-z0-9-]{3,})\s*-->/i;
|
||||
let slug = '';
|
||||
const m = (pull.body || '').match(marker);
|
||||
if (m) slug = m[1].toLowerCase();
|
||||
if (!slug) {
|
||||
const b = pull.head.ref || '';
|
||||
const im = b.match(/^issue-(\d+)$/); let issue_number = 0; if (im) issue_number = Number(im[1]);
|
||||
if (issue_number) {
|
||||
const comments = await github.paginate(github.rest.issues.listComments, { owner, repo, issue_number, per_page: 100 });
|
||||
for (const c of comments) { const mm = (c.body || '').match(marker); if (mm) { slug = mm[1].toLowerCase(); break; } }
|
||||
}
|
||||
}
|
||||
if (!slug) throw new Error('Missing codex-id marker on PR/issue.');
|
||||
const base = `preview-${slug}`;
|
||||
const rels = await github.paginate(github.rest.repos.listReleases, { owner, repo, per_page: 100 });
|
||||
let latest = base; let maxN = 0; let hasBase = false;
|
||||
for (const r of rels) {
|
||||
const t = r.tag_name || '';
|
||||
if (t === base) { hasBase = true; maxN = Math.max(maxN, 1); if (latest === base) latest = base; }
|
||||
const mm = t.match(new RegExp(`^${base}-(\\d+)$`));
|
||||
if (mm) { const n = parseInt(mm[1], 10); if (n > maxN) { maxN = n; latest = t; } }
|
||||
}
|
||||
core.setOutput('slug', slug);
|
||||
core.setOutput('tag', latest);
|
||||
- name: Upsert PR comment with artifact info
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.head.ref != 'upstream-merge'
|
||||
uses: actions/github-script@v7
|
||||
@@ -233,7 +299,8 @@ jobs:
|
||||
const pr_number = context.payload.pull_request.number;
|
||||
const runId = context.runId;
|
||||
const runUrl = `https://github.com/${owner}/${repo}/actions/runs/${runId}`;
|
||||
const tag = `preview-pr-${pr_number}`;
|
||||
const slug = `${{ steps.slug.outputs.slug }}`;
|
||||
const tag = `${{ steps.slug.outputs.tag }}`;
|
||||
const releaseBase = `https://github.com/${owner}/${repo}/releases/download/${tag}`;
|
||||
|
||||
const marker = '<!-- preview-build:artifacts -->';
|
||||
@@ -244,7 +311,7 @@ jobs:
|
||||
'',
|
||||
'You can run this right now! In your terminal just use:',
|
||||
'```bash',
|
||||
`code preview ${pr_number}`,
|
||||
`code preview ${slug}`,
|
||||
'```',
|
||||
'This will run the version of Code we\'ve created for this issue.',
|
||||
'',
|
||||
|
||||
+73
-17
@@ -156,8 +156,8 @@ struct OrderReplayArgs {
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
struct PreviewArgs {
|
||||
/// PR number whose prerelease assets to download (e.g., 115)
|
||||
pr_number: u64,
|
||||
/// Identifier: slug (preferred), or pr:<number>, or bare <number> (compat)
|
||||
id: String,
|
||||
/// Optional owner/repo to override (defaults to just-every/code or $GITHUB_REPOSITORY)
|
||||
#[arg(long = "repo", value_name = "OWNER/REPO")]
|
||||
repo: Option<String>,
|
||||
@@ -384,12 +384,72 @@ async fn preview_main(args: PreviewArgs) -> anyhow::Result<()> {
|
||||
_ => bail!(format!("Unsupported platform: {}/{}", os, arch)),
|
||||
};
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.user_agent("codex-preview/1")
|
||||
.build()?;
|
||||
let pr_number: u64 = args.pr_number;
|
||||
let client = reqwest::Client::builder().user_agent("codex-preview/1").build()?;
|
||||
|
||||
let tag = format!("preview-pr-{}", pr_number);
|
||||
// Resolve slug/tag from id
|
||||
let id = args.id.trim().to_string();
|
||||
async fn fetch_json(client: &reqwest::Client, url: &str) -> anyhow::Result<serde_json::Value> {
|
||||
let r = client.get(url).send().await?;
|
||||
let s = r.status();
|
||||
let t = r.text().await?;
|
||||
if !s.is_success() { anyhow::bail!(format!("GET {} -> {} {}", url, s.as_u16(), t)); }
|
||||
Ok(serde_json::from_str(&t).unwrap_or(serde_json::Value::Null))
|
||||
}
|
||||
async fn resolve_slug_from_pr(client: &reqwest::Client, owner: &str, name: &str, pr: u64) -> anyhow::Result<String> {
|
||||
let url = format!("https://api.github.com/repos/{owner}/{name}/pulls/{pr}");
|
||||
let v = fetch_json(client, &url).await?;
|
||||
let body = v.get("body").and_then(|x| x.as_str()).unwrap_or("");
|
||||
let re = regex::Regex::new(r"<!--\s*codex-id:\s*([a-z0-9-]{3,})\s*-->").unwrap();
|
||||
if let Some(c) = re.captures(body) { return Ok(c.get(1).unwrap().as_str().to_string()); }
|
||||
if let Some(head) = v.get("head").and_then(|h| h.get("ref")).and_then(|s| s.as_str()) {
|
||||
if let Some(m) = head.strip_prefix("issue-") { if let Ok(iss) = m.parse::<u64>() {
|
||||
let url = format!("https://api.github.com/repos/{owner}/{name}/issues/{iss}/comments");
|
||||
let arr = fetch_json(client, &url).await?;
|
||||
if let Some(a) = arr.as_array() {
|
||||
for it in a {
|
||||
let b = it.get("body").and_then(|x| x.as_str()).unwrap_or("");
|
||||
if let Some(c) = re.captures(b) { return Ok(c.get(1).unwrap().as_str().to_string()); }
|
||||
}
|
||||
}
|
||||
}}
|
||||
}
|
||||
anyhow::bail!(format!("Could not resolve codex-id for PR {}. Ensure PR body contains <!-- codex-id: ... -->.", pr))
|
||||
}
|
||||
async fn latest_tag_for_slug(client: &reqwest::Client, owner: &str, name: &str, slug: &str) -> anyhow::Result<String> {
|
||||
let base = format!("preview-{}", slug);
|
||||
let url = format!("https://api.github.com/repos/{owner}/{name}/releases?per_page=100");
|
||||
let v = fetch_json(client, &url).await?;
|
||||
let mut latest = base.clone();
|
||||
let mut max_n: u64 = 0;
|
||||
if let Some(arr) = v.as_array() {
|
||||
let re = regex::Regex::new(&format!(r"^{}-(\\d+)$", regex::escape(&base))).unwrap();
|
||||
for it in arr {
|
||||
if let Some(tag) = it.get("tag_name").and_then(|x| x.as_str()) {
|
||||
if tag == base { if max_n < 1 { max_n = 1; latest = base.clone(); } }
|
||||
else if let Some(c) = re.captures(tag) {
|
||||
let n: u64 = c.get(1).unwrap().as_str().parse().unwrap_or(0);
|
||||
if n > max_n { max_n = n; latest = tag.to_string(); }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(latest)
|
||||
}
|
||||
let (slug, tag) = if let Some(rest) = id.strip_prefix("pr:") {
|
||||
let pr: u64 = rest.parse().context("parse pr:<number>")?;
|
||||
let s = resolve_slug_from_pr(&client, &owner, &name, pr).await?;
|
||||
let t = latest_tag_for_slug(&client, &owner, &name, &s).await?;
|
||||
(s, t)
|
||||
} else if id.chars().all(|c| c.is_ascii_digit()) {
|
||||
let pr: u64 = id.parse().context("parse PR number")?;
|
||||
let s = resolve_slug_from_pr(&client, &owner, &name, pr).await?;
|
||||
let t = latest_tag_for_slug(&client, &owner, &name, &s).await?;
|
||||
(s, t)
|
||||
} else {
|
||||
let s = id.to_lowercase();
|
||||
let t = latest_tag_for_slug(&client, &owner, &name, &s).await?;
|
||||
(s, t)
|
||||
};
|
||||
let base = format!("https://github.com/{owner}/{name}/releases/download/{tag}");
|
||||
|
||||
// Try to download the best asset for this platform; prefer .tar.gz on Unix and .zip on Windows; fallback to .zst.
|
||||
@@ -460,7 +520,7 @@ async fn preview_main(args: PreviewArgs) -> anyhow::Result<()> {
|
||||
ar.unpack(&out_dir)?;
|
||||
// Find extracted binary
|
||||
let bin = first_match(&out_dir, "code-").unwrap_or(out_dir.join("code"));
|
||||
let dest_name = format!("{}-{}", bin.file_name().and_then(|s| s.to_str()).unwrap_or("code"), pr_number);
|
||||
let dest_name = format!("{}-{}", bin.file_name().and_then(|s| s.to_str()).unwrap_or("code"), slug);
|
||||
let dest = out_dir.join(dest_name);
|
||||
// Rename/move to include PR number suffix
|
||||
let _ = fs::rename(&bin, &dest).or_else(|_| { fs::copy(&bin, &dest).map(|_| () ) });
|
||||
@@ -476,13 +536,13 @@ async fn preview_main(args: PreviewArgs) -> anyhow::Result<()> {
|
||||
let mut z = ZipArchive::new(f)?;
|
||||
z.extract(&out_dir)?;
|
||||
let exe = first_match(&out_dir, "code-").unwrap_or(out_dir.join("code.exe"));
|
||||
// Append PR number before extension if present
|
||||
// Append slug before extension if present
|
||||
let dest = match exe.extension().and_then(|e| e.to_str()) {
|
||||
Some(ext) => {
|
||||
let stem = exe.file_stem().and_then(|s| s.to_str()).unwrap_or("code");
|
||||
out_dir.join(format!("{}-{}.{}", stem, pr_number, ext))
|
||||
out_dir.join(format!("{}-{}.{}", stem, slug, ext))
|
||||
}
|
||||
None => out_dir.join(format!("{}-{}", exe.file_name().and_then(|s| s.to_str()).unwrap_or("code"), pr_number)),
|
||||
None => out_dir.join(format!("{}-{}", exe.file_name().and_then(|s| s.to_str()).unwrap_or("code"), slug)),
|
||||
};
|
||||
let _ = fs::rename(&exe, &dest).or_else(|_| { fs::copy(&exe, &dest).map(|_| () ) });
|
||||
println!("Downloaded preview to {}", dest.display());
|
||||
@@ -495,16 +555,12 @@ async fn preview_main(args: PreviewArgs) -> anyhow::Result<()> {
|
||||
if path.file_name().and_then(|s| s.to_str()).map(|n| n.ends_with(".zst")).unwrap_or(false) {
|
||||
// Try to decompress .zst to 'code'
|
||||
if which::which("zstd").is_ok() {
|
||||
// Derive base name from archive (e.g., code-aarch64-apple-darwin.zst -> code-aarch64-apple-darwin-<pr>.{exe?})
|
||||
// Derive base name from archive (e.g., code-aarch64-apple-darwin.zst -> code-aarch64-apple-darwin-<slug>.{exe?})
|
||||
let stem = path
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("code");
|
||||
let dest = if cfg!(windows) {
|
||||
out_dir.join(format!("{}-{}.exe", stem, pr_number))
|
||||
} else {
|
||||
out_dir.join(format!("{}-{}", stem, pr_number))
|
||||
};
|
||||
let dest = if cfg!(windows) { out_dir.join(format!("{}-{}.exe", stem, slug)) } else { out_dir.join(format!("{}-{}", stem, slug)) };
|
||||
let status = std::process::Command::new("zstd").arg("-d").arg(&path).arg("-o").arg(&dest).status()?;
|
||||
if status.success() {
|
||||
make_exec(&dest);
|
||||
|
||||
@@ -36,9 +36,9 @@ pub(crate) struct ThemeSelectionView {
|
||||
// Revert points when backing out of detail views
|
||||
revert_theme_on_back: ThemeName,
|
||||
revert_spinner_on_back: String,
|
||||
// Scroll offsets for detail lists
|
||||
theme_scroll_offset: usize,
|
||||
spinner_scroll_offset: usize,
|
||||
// One-shot flags to show selection at top on first render of detail views
|
||||
just_entered_themes: bool,
|
||||
just_entered_spinner: bool,
|
||||
app_event_tx: AppEventSender,
|
||||
is_complete: bool,
|
||||
}
|
||||
@@ -70,8 +70,8 @@ impl ThemeSelectionView {
|
||||
overview_selected_index: 0,
|
||||
revert_theme_on_back: current_theme,
|
||||
revert_spinner_on_back: current_spinner_name,
|
||||
theme_scroll_offset: 0,
|
||||
spinner_scroll_offset: 0,
|
||||
just_entered_themes: false,
|
||||
just_entered_spinner: false,
|
||||
app_event_tx,
|
||||
is_complete: false,
|
||||
}
|
||||
@@ -155,39 +155,21 @@ impl ThemeSelectionView {
|
||||
}
|
||||
|
||||
fn move_selection_up(&mut self) {
|
||||
let options = Self::get_theme_options();
|
||||
if matches!(self.mode, Mode::Themes) {
|
||||
let count = options.len();
|
||||
if count == 0 { return; }
|
||||
let visible = 10usize.min(count);
|
||||
let middle = visible / 2;
|
||||
let options = Self::get_theme_options();
|
||||
if self.selected_theme_index > 0 {
|
||||
if self.selected_theme_index.saturating_sub(self.theme_scroll_offset) > middle {
|
||||
self.selected_theme_index -= 1;
|
||||
} else if self.theme_scroll_offset > 0 {
|
||||
self.theme_scroll_offset -= 1;
|
||||
} else {
|
||||
self.selected_theme_index -= 1;
|
||||
}
|
||||
self.selected_theme_index -= 1;
|
||||
self.current_theme = options[self.selected_theme_index].0;
|
||||
self.just_entered_themes = false;
|
||||
self.app_event_tx.send(AppEvent::PreviewTheme(self.current_theme));
|
||||
}
|
||||
} else {
|
||||
let names = crate::spinner::spinner_names();
|
||||
let count = names.len();
|
||||
if count == 0 { return; }
|
||||
let visible = 10usize.min(count);
|
||||
let middle = visible / 2;
|
||||
if self.selected_spinner_index > 0 {
|
||||
if self.selected_spinner_index.saturating_sub(self.spinner_scroll_offset) > middle {
|
||||
self.selected_spinner_index -= 1;
|
||||
} else if self.spinner_scroll_offset > 0 {
|
||||
self.spinner_scroll_offset -= 1;
|
||||
} else {
|
||||
self.selected_spinner_index -= 1;
|
||||
}
|
||||
self.selected_spinner_index -= 1;
|
||||
if let Some(name) = names.get(self.selected_spinner_index) {
|
||||
self.current_spinner = name.clone();
|
||||
self.just_entered_spinner = false;
|
||||
self.app_event_tx
|
||||
.send(AppEvent::PreviewSpinner(self.current_spinner.clone()));
|
||||
}
|
||||
@@ -198,17 +180,20 @@ impl ThemeSelectionView {
|
||||
fn move_selection_down(&mut self) {
|
||||
if matches!(self.mode, Mode::Themes) {
|
||||
let options = Self::get_theme_options();
|
||||
self.selected_theme_index = (self.selected_theme_index + 1) % options.len();
|
||||
self.current_theme = options[self.selected_theme_index].0;
|
||||
// Live preview - update theme immediately (no history event)
|
||||
self.app_event_tx
|
||||
.send(AppEvent::PreviewTheme(self.current_theme));
|
||||
if self.selected_theme_index + 1 < options.len() {
|
||||
self.selected_theme_index += 1;
|
||||
self.current_theme = options[self.selected_theme_index].0;
|
||||
self.just_entered_themes = false;
|
||||
self.app_event_tx
|
||||
.send(AppEvent::PreviewTheme(self.current_theme));
|
||||
}
|
||||
} else {
|
||||
let names = crate::spinner::spinner_names();
|
||||
if !names.is_empty() {
|
||||
self.selected_spinner_index = (self.selected_spinner_index + 1) % names.len();
|
||||
if self.selected_spinner_index + 1 < names.len() {
|
||||
self.selected_spinner_index += 1;
|
||||
if let Some(name) = names.get(self.selected_spinner_index) {
|
||||
self.current_spinner = name.clone();
|
||||
self.just_entered_spinner = false;
|
||||
self.app_event_tx
|
||||
.send(AppEvent::PreviewSpinner(self.current_spinner.clone()));
|
||||
}
|
||||
@@ -309,12 +294,13 @@ impl<'a> BottomPaneView<'a> for ThemeSelectionView {
|
||||
if self.overview_selected_index == 0 {
|
||||
self.revert_theme_on_back = self.current_theme;
|
||||
self.mode = Mode::Themes;
|
||||
self.just_entered_themes = true;
|
||||
} else {
|
||||
self.revert_spinner_on_back = self.current_spinner.clone();
|
||||
self.mode = Mode::Spinner;
|
||||
// Keep preview animating
|
||||
self.app_event_tx
|
||||
.send(AppEvent::ScheduleFrameIn(std::time::Duration::from_millis(120)));
|
||||
self.just_entered_spinner = true;
|
||||
}
|
||||
}
|
||||
Mode::Themes => self.confirm_theme(),
|
||||
@@ -395,20 +381,8 @@ impl<'a> BottomPaneView<'a> for ThemeSelectionView {
|
||||
let padded = inner.inner(ratatui::layout::Margin::new(1, 1));
|
||||
let body_area = padded;
|
||||
|
||||
// List height equals available body height
|
||||
// Visible rows = available body height (already sized to ≤10)
|
||||
let available_height = body_area.height as usize;
|
||||
// Use stored scroll offsets; clamp to bounds
|
||||
let scroll_offset = match self.mode {
|
||||
Mode::Themes => {
|
||||
let count = options.len();
|
||||
self.theme_scroll_offset.min(count.saturating_sub(available_height))
|
||||
}
|
||||
Mode::Spinner => {
|
||||
let count = crate::spinner::spinner_names().len();
|
||||
self.spinner_scroll_offset.min(count.saturating_sub(available_height))
|
||||
}
|
||||
Mode::Overview => 0,
|
||||
};
|
||||
|
||||
// Create body content
|
||||
let mut lines = Vec::new();
|
||||
@@ -439,10 +413,22 @@ impl<'a> BottomPaneView<'a> for ThemeSelectionView {
|
||||
lines.push(Line::from(spans));
|
||||
}
|
||||
} else if matches!(self.mode, Mode::Themes) {
|
||||
// Add visible themes based on stored scroll offset
|
||||
let visible = available_height;
|
||||
let start = scroll_offset.min(options.len().saturating_sub(visible));
|
||||
let end = (start + visible).min(options.len());
|
||||
// Compute anchored window: top until middle, then center; bottom shows end
|
||||
let count = options.len();
|
||||
let visible = available_height.min(10).max(1);
|
||||
let middle = visible / 2;
|
||||
let start = if self.just_entered_themes {
|
||||
self.selected_theme_index.min(count.saturating_sub(visible))
|
||||
} else if count <= visible {
|
||||
0
|
||||
} else if self.selected_theme_index < middle {
|
||||
0
|
||||
} else if self.selected_theme_index > count.saturating_sub(visible - middle) {
|
||||
count.saturating_sub(visible)
|
||||
} else {
|
||||
self.selected_theme_index - middle
|
||||
};
|
||||
let end = (start + visible).min(count);
|
||||
for i in start..end {
|
||||
let (theme_enum, name, description) = &options[i];
|
||||
let is_selected = i == self.selected_theme_index;
|
||||
@@ -492,9 +478,21 @@ impl<'a> BottomPaneView<'a> for ThemeSelectionView {
|
||||
.unwrap_or_default()
|
||||
.as_millis();
|
||||
let names = crate::spinner::spinner_names();
|
||||
let visible = available_height;
|
||||
let start = scroll_offset.min(names.len().saturating_sub(visible));
|
||||
let end = (start + visible).min(names.len());
|
||||
let count = names.len();
|
||||
let visible = available_height.min(10).max(1);
|
||||
let middle = visible / 2;
|
||||
let start = if self.just_entered_spinner {
|
||||
self.selected_spinner_index.min(count.saturating_sub(visible))
|
||||
} else if count <= visible {
|
||||
0
|
||||
} else if self.selected_spinner_index < middle {
|
||||
0
|
||||
} else if self.selected_spinner_index > count.saturating_sub(visible - middle) {
|
||||
count.saturating_sub(visible)
|
||||
} else {
|
||||
self.selected_spinner_index - middle
|
||||
};
|
||||
let end = (start + visible).min(count);
|
||||
for i in start..end {
|
||||
let name = names[i].clone();
|
||||
let is_selected = i == self.selected_spinner_index;
|
||||
|
||||
Reference in New Issue
Block a user