Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- `:freeze` and `:unfreeze` commands to freeze and unfreeze rows and columns at the current or specified cell.

## [1.3.1] - 2026-04-24

### Added
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -398,6 +398,9 @@ The JSON files are saved in the same directory as the original Excel file.
- `:dc` - Delete the current column
- `:dc [col]` - Delete a specific column (e.g., `:dc A` or `:dc a` or `:dc 1` all delete column A)
- `:dc [start] [end]` - Delete a range of columns (e.g., `:dc A C` or `:dc a c` deletes columns A through C)
- `:freeze` - Freeze rows above and columns left of the current cell
- `:freeze [cell]` - Freeze panes at a specific cell (e.g., `:freeze B2` freezes row 1 and column A)
- `:unfreeze` - Clear frozen panes for the current sheet

### Other Commands

Expand Down
3 changes: 3 additions & 0 deletions README_zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -399,6 +399,9 @@ JSON 文件保存在原始 Excel 文件所在目录。
- `:dc` — 删除当前列
- `:dc [列]` — 删除指定列(如 `:dc A`、`:dc a` 或 `:dc 1` 都删除 A 列)
- `:dc [起始] [结束]` — 删除列范围(如 `:dc A C` 或 `:dc a c` 删除 A 到 C 列)
- `:freeze` — 按当前单元格冻结其上方行和左侧列
- `:freeze [单元格]` — 按指定单元格冻结窗格(如 `:freeze B2` 冻结第 1 行和 A 列)
- `:unfreeze` — 取消当前工作表的冻结窗格

### 其他命令

Expand Down
8 changes: 8 additions & 0 deletions src/app/help.rs
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,14 @@ pub const LEFT_HELP_SECTIONS: &[HelpSection] = &[
keys: ":dc <start> <end>",
description: "Delete column range",
},
HelpEntry {
keys: ":freeze [cell]",
description: "Freeze panes at cell",
},
HelpEntry {
keys: ":unfreeze",
description: "Clear frozen panes",
},
],
},
];
Expand Down
52 changes: 43 additions & 9 deletions src/app/navigation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,27 @@ impl AppState<'_> {
}

pub fn handle_scrolling(&mut self) {
if self.selected_cell.0 < self.start_row {
self.start_row = self.selected_cell.0;
} else if self.selected_cell.0 >= self.start_row + self.visible_rows {
self.start_row = self.selected_cell.0 - self.visible_rows + 1;
let frozen_rows = self.workbook.get_current_sheet().freeze_panes.rows;
let min_scroll_row = frozen_rows + 1;

if frozen_rows > 0 && self.start_row < min_scroll_row {
self.start_row = min_scroll_row;
}

if self.selected_cell.0 > frozen_rows {
let frozen_rows_visible = if self.visible_rows > 1 {
frozen_rows.min(self.visible_rows - 1)
} else {
0
};
let scroll_rows_visible = self.visible_rows.saturating_sub(frozen_rows_visible).max(1);

if self.selected_cell.0 < self.start_row {
self.start_row = self.selected_cell.0.max(min_scroll_row);
} else if self.selected_cell.0 >= self.start_row + scroll_rows_visible {
self.start_row =
(self.selected_cell.0 - scroll_rows_visible + 1).max(min_scroll_row);
}
}

self.handle_column_scrolling();
Expand Down Expand Up @@ -138,17 +155,34 @@ impl AppState<'_> {
}

pub fn ensure_column_visible(&mut self, column: usize) {
let frozen_cols = self.workbook.get_current_sheet().freeze_panes.cols;
let min_scroll_col = frozen_cols + 1;

if frozen_cols > 0 && self.start_col < min_scroll_col {
self.start_col = min_scroll_col;
}

if column <= frozen_cols {
return;
}

// If column is to the left of visible area, adjust start_col
if column < self.start_col {
self.start_col = column;
self.start_col = column.max(min_scroll_col);
return;
}

let last_visible_col = self.start_col + self.visible_cols - 1;
let frozen_cols_visible = if self.visible_cols > 1 {
frozen_cols.min(self.visible_cols - 1)
} else {
0
};
let scroll_cols_visible = self.visible_cols.saturating_sub(frozen_cols_visible).max(1);
let last_visible_col = self.start_col + scroll_cols_visible - 1;

// If column is to the right of visible area, adjust start_col to make it visible
if column > last_visible_col {
self.start_col = (column - self.visible_cols + 1).max(1);
self.start_col = (column - scroll_cols_visible + 1).max(min_scroll_col);
return;
}

Expand All @@ -157,10 +191,10 @@ impl AppState<'_> {
let max_col = sheet.max_cols;

// Only apply margin logic if not at the max column
if column < max_col && column == last_visible_col && self.visible_cols > 1 {
if column < max_col && column == last_visible_col && scroll_cols_visible > 1 {
// Adjust start column to show more columns to the left
// This creates a margin on the right
self.start_col = (column - (self.visible_cols - 2)).max(1);
self.start_col = (column - (scroll_cols_visible - 2)).max(min_scroll_col);
}
}
}
1 change: 1 addition & 0 deletions src/cli/check.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1046,6 +1046,7 @@ mod tests {
max_rows,
max_cols,
is_loaded: true,
freeze_panes: crate::excel::FreezePanes::none(),
}
}

Expand Down
3 changes: 2 additions & 1 deletion src/cli/sheet_query.rs
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,7 @@ pub(crate) fn read_header_values(
#[cfg(test)]
mod tests {
use super::*;
use crate::excel::{CellType, Sheet};
use crate::excel::{CellType, FreezePanes, Sheet};

fn sheet_with_values(name: &str, values: &[&[&str]]) -> Sheet {
let max_rows = values.len();
Expand All @@ -208,6 +208,7 @@ mod tests {
max_rows,
max_cols,
is_loaded: true,
freeze_panes: FreezePanes::none(),
}
}

Expand Down
138 changes: 101 additions & 37 deletions src/commands/executor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ use std::path::Path;

use crate::app::AppState;
use crate::json_export::{export_all_sheets_json, export_json, HeaderDirection};
use crate::utils::col_name_to_index;
use crate::utils::{cell_reference, col_name_to_index, parse_cell_reference};

impl AppState<'_> {
pub fn execute_command(&mut self) {
Expand Down Expand Up @@ -53,6 +53,8 @@ impl AppState<'_> {
"nohlsearch" | "noh" => self.disable_search_highlight(),
"help" => self.show_help(),
"delsheet" => self.delete_current_sheet(),
"freeze" => self.freeze_at_cell(self.selected_cell),
"unfreeze" => self.clear_freeze_panes(),
"addsheet" => self.add_notification("Usage: :addsheet <name>".to_string()),
_ => {
// Handle commands with parameters
Expand All @@ -69,13 +71,43 @@ impl AppState<'_> {
self.handle_delete_row_command(&command);
} else if command.starts_with("dc") {
self.handle_delete_column_command(&command);
} else if let Some(cell_ref) = command.strip_prefix("freeze ") {
self.handle_freeze_command(cell_ref.trim());
} else {
self.add_notification(format!("Unknown command: {}", command));
}
}
}
}

fn handle_freeze_command(&mut self, cell_ref: &str) {
let Some(cell) = parse_cell_reference(cell_ref) else {
self.add_notification("Usage: :freeze [cell]".to_string());
return;
};

self.freeze_at_cell(cell);
}

fn freeze_at_cell(&mut self, cell: (usize, usize)) {
let (row, col) = cell;
if row == 1 && col == 1 {
self.clear_freeze_panes();
return;
}

self.workbook
.set_freeze_panes(row.saturating_sub(1), col.saturating_sub(1));
self.handle_scrolling();
self.add_notification(format!("Frozen panes at {}", cell_reference(cell)));
}

fn clear_freeze_panes(&mut self) {
self.workbook.clear_freeze_panes();
self.handle_scrolling();
self.add_notification("Freeze panes cleared".to_string());
}

fn handle_column_width_command(&mut self, cmd: &str) {
let parts: Vec<&str> = cmd.split_whitespace().collect();

Expand Down Expand Up @@ -346,14 +378,7 @@ impl AppState<'_> {
}

self.selected_cell = (row, col);
// Handle scrolling
if self.selected_cell.0 < self.start_row {
self.start_row = self.selected_cell.0;
} else if self.selected_cell.0 >= self.start_row + self.visible_rows {
self.start_row = self.selected_cell.0 - self.visible_rows + 1;
}

self.ensure_column_visible(self.selected_cell.1);
self.handle_scrolling();

self.add_notification(format!(
"Jumped to cell {}{}",
Expand All @@ -363,37 +388,34 @@ impl AppState<'_> {
}
}

// Parse a cell reference like "A1", "B10", etc.
fn parse_cell_reference(input: &str) -> Option<(usize, usize)> {
// Cell references should have at least 2 characters (e.g., A1)
if input.chars().count() < 2 {
return None;
}

// Find the first digit to separate column and row parts
let col_end = input
.char_indices()
.find(|(_, c)| c.is_ascii_digit())
.map(|(index, _)| index)?;

if col_end == 0 {
return None; // No digits found
}

let (col_part, row_part) = input.split_at(col_end);

// Convert column letters to index
let col = col_name_to_index(&col_part.to_uppercase())?;

// Parse row number
let row = row_part.parse::<usize>().ok()?;

Some((row, col))
}

#[cfg(test)]
mod tests {
use super::parse_cell_reference;
use crate::app::AppState;
use crate::excel::{Cell, FreezePanes, Sheet, Workbook};
use std::path::PathBuf;

fn app_with_sheet() -> AppState<'static> {
let mut data = vec![vec![Cell::empty(); 3]; 3];
data[1][1] = Cell::new("Name".to_string(), false);
data[1][2] = Cell::new("Name".to_string(), false);
data[2][1] = Cell::new("Ada".to_string(), false);
data[2][2] = Cell::new("10".to_string(), false);
let sheet = Sheet {
name: "Data".to_string(),
data,
max_rows: 2,
max_cols: 2,
is_loaded: true,
freeze_panes: FreezePanes::none(),
};

AppState::new(
Workbook::from_sheets_for_test(vec![sheet]),
PathBuf::from("test.xlsx"),
)
.unwrap()
}

#[test]
fn parses_valid_cell_references() {
Expand All @@ -406,4 +428,46 @@ mod tests {
assert_eq!(parse_cell_reference("addsheet 测试1"), None);
assert_eq!(parse_cell_reference("测试1"), None);
}

#[test]
fn freeze_command_uses_current_cell_and_marks_workbook_modified() {
let mut app = app_with_sheet();
app.selected_cell = (2, 2);
app.input_buffer = "freeze".to_string();

app.execute_command();

let sheet = app.workbook.get_current_sheet();
assert_eq!(sheet.freeze_panes.rows, 1);
assert_eq!(sheet.freeze_panes.cols, 1);
assert!(app.workbook.is_modified());
assert!(app.undo_history.all_undone());
}

#[test]
fn freeze_command_accepts_explicit_cell_and_a1_clears() {
let mut app = app_with_sheet();

app.input_buffer = "freeze B2".to_string();
app.execute_command();
assert_eq!(
app.workbook.get_current_sheet().freeze_panes.split_cell(),
(2, 2)
);

app.input_buffer = "freeze A1".to_string();
app.execute_command();
assert!(!app.workbook.get_current_sheet().freeze_panes.is_frozen());
}

#[test]
fn unfreeze_command_clears_freeze_panes() {
let mut app = app_with_sheet();
app.workbook.set_freeze_panes(1, 1);

app.input_buffer = "unfreeze".to_string();
app.execute_command();

assert!(!app.workbook.get_current_sheet().freeze_panes.is_frozen());
}
}
Loading
Loading