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:
James Peter
2025-09-10 09:28:27 +10:00
parent 867fe3c861
commit ad859a338b
6 changed files with 270 additions and 85 deletions
+19 -2
View File
@@ -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 {}
+51 -5
View File
@@ -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.',
'',
+2 -1
View File
@@ -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
+71 -4
View File
@@ -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
View File
@@ -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;