Add row\_offset to Editor State for Viewport Scrolling
Mục tiêu: Introduce a row\_offset field to the Editor struct and initialize it. This architectural change lays the foundation for implementing vertical scrolling by tracking which row of the file is at the top of the screen.
An enormous congratulations on completing the status bar! You’ve built a professional, dynamic, and visually distinct UI component that gives the user crucial, real-time feedback. Your editor is no longer a black box; its internal state, including the filename and dirty status, is now clearly visible. This is a massive leap forward in user experience.
However, your editor currently has a hidden limitation: it’s bound by the physical dimensions of the terminal. If a user opens a file with more lines than the screen can display, they have no way to see or edit the content that’s “off-screen.” We are about to shatter that limitation. Welcome to the next great challenge: implementing viewport scrolling.
The Viewport: A Camera on Your Document
The core concept we will implement is that of a viewport. Think of your text buffer, the rows: Vec<String>, as a very long scroll of paper. Your physical terminal screen is a small camera viewfinder that can only look at a portion of that scroll at any given time.
To know which part of the scroll we’re looking at, we need to track our camera’s vertical position. This is the fundamental purpose of our current task. We will introduce a new piece of state to our Editor that tracks how many lines we have scrolled down from the top of the file. We will call this the row_offset.
- If
row_offsetis0, our camera is at the very top. The first line on the screen is the first line of the file. - If
row_offsetis10, our camera has panned down 10 lines. The first 10 lines of the file are “above” the screen, and the first line displayed on the screen is actually line 11 (index 10) from our text buffer.
This single piece of state is the key that unlocks our ability to navigate documents of any size.
Adding row_offset to the Editor’s State
Our first step is purely architectural. We must add this new row_offset field to our Editor struct, making it a fundamental part of the editor’s state. We also need to initialize it in the Editor::new() constructor.
1. Update the Editor Struct
Modify your Editor struct definition in src/main.rs to include the new field.
// src/main.rs
// ... other fields in Editor struct
struct Editor {
cursor_x: usize,
cursor_y: usize,
screen_rows: usize,
screen_cols: usize,
rows: Vec<String>,
filename: Option<PathBuf>,
status_message: String,
dirty: bool,
// --- NEW FIELD ---
// Tracks which row of the file is at the top of the screen.
row_offset: usize,
}
2. Initialize row_offset in the Constructor
Now, modify the Editor::new() constructor to initialize this new field. When a file is first opened or a new editor is created, we always start at the very top of the document, so the offset should be 0.
// src/main.rs -> inside the `impl Editor` block
fn new(filename: Option<String>) -> Self {
let (cols, rows) = terminal::size().unwrap();
let mut editor = Self {
cursor_x: 0,
cursor_y: 0,
screen_rows: rows as usize,
screen_cols: cols as usize,
rows: Vec::new(),
filename: filename.map(PathBuf::from),
status_message: String::new(),
dirty: false,
// --- INITIALIZE THE NEW FIELD ---
// The editor always starts at the top of the file.
row_offset: 0,
};
// ... rest of the `new` method is unchanged
editor
}
Code and Concept Deep Dive
row_offset: usize: This is our new state variable. We chooseusizebecause an offset is a count of lines, which is a non-negative integer. It perfectly matches the type used for indexing vectors and is the most idiomatic choice in Rust for sizes and counts.row_offset: 0: We initialize the offset to0. This ensures that every time the editor starts, the view is positioned at the very beginning of the document, which is the universal and expected behavior for any text editor.- An Invisible Foundation: If you
cargo runyour editor now, you will see no change whatsoever. This is expected and correct! Adding a field to a struct is a pure Model change. You have added a new piece of information to the editor’s brain, but you haven’t yet told the View (the rendering logic) how to use that information. This is a crucial step in building complex features: you first establish the state, and then you make the UI react to it.
You have now successfully laid the architectural foundation for all scrolling functionality. The editor is now aware of the concept of a viewport, even if it can’t act on it yet.
Next Steps
The stage is perfectly set. You have the row_offset stored in your Editor’s state, waiting to be used. The very next task is to modify the draw_rows method. You will teach it to read self.row_offset and use it to calculate which line of the text buffer should be drawn on each row of the screen. This is where your viewport will come to life.
Further Reading
- State Management in Applications: As your editor’s state grows more complex (
cursor_x,cursor_y,dirty,row_offset), it’s helpful to read about state management as a general concept in software design. - Game Development Viewports/Cameras: The concept of a viewport is fundamental in game development and 2D graphics. Reading about it in that context can provide a very strong and visual mental model for what you are building.
- Rust Structs (The Rust Book): A great refresher on how to define and instantiate structs in Rust, which is the core of your editor’s state management.
Implement a Viewport for Vertical Scrolling
Mục tiêu: Modify the draw\_rows function to use the row\_offset to render a viewport of the text file, separating physical screen coordinates from logical file coordinates.
Excellent work on the previous task. You have successfully laid the architectural foundation for scrolling by adding the row_offset field to your editor’s state. This new field is the editor’s “memory” of its vertical scroll position, the single source of truth for which part of a document is currently in view.
Right now, that memory is disconnected from reality. The rendering logic in draw_rows still naively assumes that screen row 0 is always file row 0. Our current task is to bridge this gap. We will teach draw_rows to read the row_offset and use it to render a true viewport, a movable “camera” looking at your text buffer.
From Physical to Logical Coordinates
The core concept we are about to implement is the separation of physical coordinates (the rows of the terminal screen) from logical coordinates (the rows of the text file).
- Physical Screen Row
y: This is the loop variable,y, that iterates from0up toself.screen_rows - 2. It represents a tangible row on the user’s screen. - Logical File Row
file_row: This is the actual line number from yourself.rowsvector that you want to display on physical screen rowy.
The relationship between them is the simple but powerful formula you’ve been building towards:
logical_file_row = physical_screen_row + row_offset
By implementing this formula, your rendering logic will no longer be fixed to the top of the file. It will be able to draw any slice of your document onto the screen, directed by the value of self.row_offset.
Modifying draw_rows for the Viewport
Let’s put this concept into practice. We will modify the draw_rows method. The change is small but has a profound impact on the editor’s capabilities. We will calculate the logical file_row on each iteration of the loop and use that to access our text buffer.
Here are the highlighted changes for your draw_rows method in src/main.rs.
// src/main.rs -> inside the `impl Editor` block
fn draw_rows(&self) {
// The loop still iterates over the visible rows of the screen.
for y in 0..self.screen_rows - 1 {
// --- THIS IS THE NEW LOGIC FOR OUR TASK ---
// For each screen row `y`, we calculate the corresponding file row
// by adding the current scroll offset. This is the core of our viewport.
let file_row = y + self.row_offset;
// We now check if the calculated `file_row` points to an actual line
// in our document. If it does not, we draw a tilde or welcome message.
if file_row >= self.rows.len() {
// ------------------------------------------
// The welcome message is still positioned relative to the physical screen,
// so this logic block remains the same. It only appears on an empty document.
if self.rows.is_empty() && y == self.screen_rows / 3 {
let message = format!("Kilo-rs Editor -- Version {}", env!("CARGO_PKG_VERSION"));
let padding = (self.screen_cols.saturating_sub(message.len())) / 2;
let mut welcome = String::new();
if padding > 0 {
welcome.push('~');
welcome.push_str(&" ".repeat(padding));
}
welcome.push_str(&message);
if welcome.len() > self.screen_cols {
welcome.truncate(self.screen_cols);
}
println!("{}\r", welcome);
} else {
println!("~\r");
}
} else {
// --- MODIFIED LINE ---
// If `file_row` is a valid line, we print it to the screen.
// Instead of `self.rows[y]`, we now use `self.rows[file_row]`.
let mut row = self.rows[file_row].clone();
if row.len() > self.screen_cols {
row.truncate(self.screen_cols);
}
println!("{}\r", row);
// ---------------------
}
}
}
Code and Concept Deep Dive
Let’s break down the changes:
let file_row = y + self.row_offset;: This is the heart of the viewport logic. On every iteration, for every physical screen rowy, we calculate the logicalfile_rowwe need to display.if file_row >= self.rows.len(): Our condition for drawing a~has been updated. Previously, we compared the physical rowyto the total number of lines. Now, we correctly compare the logicalfile_row. This ensures that if we scroll past the end of the file, the editor correctly fills the rest of the screen with tildes.println!("{}\r", self.rows[file_row]);: This is the payoff. Instead of accessingself.rows[y], we now accessself.rows[file_row]. Our rendering engine is now looking through the “camera” whose position is determined byrow_offset. I’ve also added some basic line truncation logic here as a good practice, preparing for horizontal scrolling later.
What to Expect (and Why)
Go ahead and cargo run your editor, both with and without a file argument. You will see… absolutely no change.
This is not a bug; it is the expected outcome! Your rendering logic is now much more powerful, but you haven’t yet built the controls to change self.row_offset. It is still hardcoded to 0. Because row_offset is 0, the formula file_row = y + 0 simplifies to file_row = y, which is the exact same logic you had before.
You have successfully re-wired your rendering engine. You’ve installed a powerful new transmission, but it’s still stuck in first gear. This internal change is a massive step forward, and it has perfectly prepared you for the next task.
Next Steps
The engine is ready. Now you need to build the controls. The very next task is to create a scroll(&mut self) method. This method’s job will be to look at the cursor’s position after every movement and intelligently update self.row_offset to ensure the cursor is always visible within the viewport. This is where you will see your editor truly break free from the screen’s boundaries.
Further Reading
- Logical vs. Physical Addresses: The concept you’ve just implemented is fundamental across computer science. A similar idea is used in memory management, where a program uses “logical” memory addresses that are translated to “physical” RAM addresses by the operating system.
- Array and Vector Indexing in Rust: A solid refresher on how indexing works in Rust, which is what you are doing with
self.rows[file_row]. - 2D Game Cameras: For a highly visual and intuitive analogy to what you’ve just built, read about how 2D game cameras work. A game camera tracks an offset to render a small part of a large game world, which is exactly what you are doing with your text file.
Implement Vertical Scrolling
Mục tiêu: Create a scroll method to dynamically adjust the editor’s vertical row\_offset. This method will ensure the cursor always remains within the visible screen area, enabling navigation in files larger than the screen.
In the previous task, you brilliantly rewired your editor’s rendering engine. By teaching draw_rows to respect the row_offset, you made it capable of displaying a viewport. However, row_offset is still static, forever stuck at 0. Your powerful rendering engine is like a camera fixed on a tripod at the very top of your document.
Our current task is to cut that camera loose. We will create a scroll(&mut self) method, the brains of our viewport system. This method’s crucial job is to constantly watch the cursor’s position and intelligently move the viewport (by changing row_offset) to ensure the cursor never goes out of view. This is the final piece of the puzzle that will unlock navigation in files of any size.
Architecting the scroll Method
The logic for our scroll method is based on two simple, intuitive rules that mimic how any text editor or web browser behaves:
- If the cursor moves above the top of the screen: The viewport must scroll up so that the cursor’s line becomes the new top line.
- If the cursor moves below the bottom of the screen: The viewport must scroll down so that the cursor appears on the new bottom line.
This method will need to change the editor’s state (self.row_offset), so its signature must be fn scroll(&mut self).
Step 1: Create the scroll Method
Let’s add the new scroll method to your impl Editor block in src/main.rs. We will implement the vertical scrolling logic first.
// src/main.rs -> inside the `impl Editor` block
// ... (other methods) ...
/// Adjusts the `row_offset` to ensure the cursor is always within the
/// visible portion of the screen (the viewport).
fn scroll(&mut self) {
// RULE 1: If the cursor is above the visible window.
// We check if the cursor's Y position is less than the current row offset.
if self.cursor_y < self.row_offset {
// If it is, we've moved off the top of the screen.
// We set the row offset to be the cursor's current line, effectively
// scrolling the view to bring the cursor to the top of the screen.
self.row_offset = self.cursor_y;
}
// RULE 2: If the cursor is at or below the bottom of the visible window.
// First, we calculate the height of the text area. This is the total number
// of screen rows minus one for the status bar.
let text_area_height = self.screen_rows - 1;
// We check if the cursor's Y position has gone past the last visible line.
// The last visible line is `row_offset + text_area_height`.
if self.cursor_y >= self.row_offset + text_area_height {
// If it has, we've moved off the bottom of the screen.
// We scroll the view down so that the cursor's line becomes the new
// bottom line of the screen.
self.row_offset = self.cursor_y - text_area_height + 1;
}
}
Code and Concept Deep Dive
Let’s break down the logic in detail:
if self.cursor_y < self.row_offset: This is the check for scrolling up. It’s simple and direct. If the cursor’s logical position in the file is smaller than the logical position of the first line on the screen, the cursor is out of view at the top.self.row_offset = self.cursor_y: The fix is equally simple. We move the viewport’s top edge to match the cursor’s line.let text_area_height = self.screen_rows - 1: This is a crucial calculation. Your terminal hasscreen_rowstotal rows, but you reserved the very last one for the status bar. Therefore, the area available for drawing text is one row smaller.if self.cursor_y >= self.row_offset + text_area_height: This is the check for scrolling down.self.row_offsetis the file line number at the top of the screen (e.g., line 10).text_area_heightis how many lines we can see (e.g., 24 lines).self.row_offset + text_area_heightgives us the file line number of the first line just below the visible screen (e.g.,10 + 24 = 34).- So, if the cursor is at or past this line, it’s off-screen at the bottom.
self.row_offset = self.cursor_y - text_area_height + 1: This calculation repositions the viewport. It subtracts the height of the screen from the cursor’s position to find the new top line, ensuring the cursor is now visible at the very bottom.
Step 2: Call scroll After Cursor Movement
This powerful new method does nothing if it’s never called. The roadmap states it should be called “after every cursor movement.” The most robust way to ensure this in your current application structure is to call it after any keypress is processed in your main event loop. This guarantees that whether the cursor moves from an arrow key, the Enter key, or a backspace, the scroll check is always performed.
Modify your main function’s loop to add a call to editor.scroll().
// src/main.rs -> inside the `main` function's loop
loop {
editor.refresh_screen();
if let Event::Key(key_event) = event::read()? {
match key_event.code {
// ... (all your existing KeyCode::... match arms are unchanged)
KeyCode::Char('s') if key_event.modifiers == KeyModifiers::CONTROL => editor.save(),
KeyCode::Char(c) => editor.insert_char(c),
KeyCode::Backspace => editor.delete_char(),
KeyCode::Enter => editor.insert_newline(),
_ => {}
}
// --- NEW CODE FOR THIS TASK ---
// After every single keypress that might have changed the cursor's
// position, we call our new `scroll` method. This ensures the viewport
// is always adjusted correctly.
editor.scroll();
// ----------------------------
}
}
The Payoff: A Liberated Viewport
Congratulations! Your editor is now free from the tyranny of the physical screen.
Open a large text file with your editor (cargo run -- my_large_file.txt). Now, hold down the Down arrow key. As your cursor reaches the bottom of the screen, you will see the text begin to smoothly scroll upwards. The row_offset is increasing, your draw_rows method is rendering the correct slice of the file, and the scroll method is orchestrating it all. You have successfully implemented vertical scrolling.
Next Steps
Your viewport can move up and down, but what about left and right? For lines that are wider than the terminal, the user still can’t see the content that’s off-screen. The logical next step is to repeat the process you just mastered, but for horizontal scrolling. You will add a col_offset to your editor’s state and expand the scroll method to manage it.
Further Reading
- Software Design Patterns: Observer Pattern: The relationship you’ve built—where the
scrollmethod “observes” the state of the cursor and reacts to changes—is a form of the Observer design pattern. Understanding this pattern can help you structure more complex, reactive applications. - 2D Camera Systems in Game Development: For a highly visual and deep analogy, reading about 2D “camera follow” scripts in game engines will show you the exact same logic you just implemented, but in a different context.
- Rust Control Flow (The Rust Book): A great refresher on the
ifexpressions that form the core of your scrolling logic.
Implement Upward Scrolling Logic in a Text Editor
Mục tiêu: Add the first rule to the viewport’s scroll method to handle upward scrolling. This involves detecting when the cursor moves above the visible screen and adjusting the row\_offset to bring the cursor back into view.
In the last task, you brilliantly created the scroll method and integrated it into your main event loop. This method is the designated “brain” for your viewport system, poised to react every time the user moves the cursor. Right now, that brain is a blank slate; it exists, but it has no rules to follow.
Our current task is to teach it its very first, fundamental rule: what to do when the cursor moves above the top of the visible screen. This is the logic that will allow a user to scroll back up through a large file.
The Core Logic: Detecting an “Off-Screen” Cursor
To implement this rule, we must first define what it means for the cursor to be “above the window” using the state variables we have:
self.cursor_y: The cursor’s logical row number within the entire file. This is its absolute position.self.row_offset: The logical row number of the file that is currently being displayed at the very top of the screen (physical row 0). This is the viewport’s position.
The condition is surprisingly simple: if the cursor’s logical position is less than the viewport’s logical position, the cursor must be above the visible area.
For example, imagine your screen is showing lines 10 through 34 of a file. This means self.row_offset is 10. If the user presses the up arrow and self.cursor_y becomes 9, the condition 9 < 10 is true. The cursor is now invisible, one line above the top of the screen. Our code must detect this and react.
The reaction is just as simple: if the cursor is above the screen, we must move the screen up so the cursor is visible again. The most intuitive way to do this is to make the cursor’s line the new top line of the screen.
Implementing the First Scrolling Rule
Let’s add this logic to your scroll method. This is the first of two rules that will govern vertical scrolling.
// src/main.rs -> inside the `impl Editor` block
/// Adjusts the `row_offset` to ensure the cursor is always within the
/// visible portion of the screen (the viewport).
fn scroll(&mut self) {
// --- THIS IS THE NEW CODE FOR OUR TASK ---
// RULE 1: If the cursor is above the visible window.
// We check if the cursor's logical Y position in the file (`cursor_y`) is
// less than the file line currently at the top of the screen (`row_offset`).
if self.cursor_y < self.row_offset {
// If it is, we've moved off the top of the screen.
// We set the row offset to be the cursor's current line, effectively
// scrolling the view to bring the cursor to the new top of the screen.
self.row_offset = self.cursor_y;
}
// --- END OF NEW CODE ---
}
Code and Concept Deep Dive
Let’s break down this new if block:
if self.cursor_y < self.row_offset: This is the condition that triggers our logic. It’s a direct comparison of the cursor’s absolute position and the viewport’s absolute position. This elegant line is the complete “detector” for when the user has scrolled off the top of the screen.self.row_offset = self.cursor_y;: This is the action. We are modifying the editor’s state by changing the vertical scroll position. By setting therow_offsetto equal thecursor_y, we ensure that the next timedraw_rowsis called, it will start rendering from the exact line the cursor is on. The cursor will now be visible on the very first line of the terminal.
What to Expect (and Why It’s Correct)
After adding this code, if you run your editor and open a large file, you might be tempted to think nothing has changed. This is expected!
You have implemented the logic for scrolling up, but to test it, you first need to be able to scroll down. Right now, your cursor is trapped within the initial screen view. You have built the top half of a ladder, but you can’t climb down to then climb back up. The logic you’ve added is correct and essential, but it lies dormant, waiting for its counterpart to be implemented.
You have successfully taught your editor the first half of a new, complex skill.
Next Steps
The stage is perfectly set to complete the feature. The logical next step is to implement the second rule inside the scroll method: check if cursor_y is at or below the bottom of the visible window. This will involve a slightly more complex calculation to determine the position of the last visible line and will enable your editor to scroll down, making the full vertical scrolling feature come to life.
Further Reading
To deepen your understanding of the concepts you’re using, check out these resources:
- Software Design Patterns: The Observer Pattern: The relationship where your
scrollmethod “observes” the cursor’s state and reacts is a classic software pattern. Understanding it will help you architect more complex applications. - Rust Control Flow:
ifExpressions: The core of your new logic is a simpleifstatement. A quick review of the Rust Book’s section on control flow is always valuable. - 2D Camera Logic in Game Development: The viewport you are building is conceptually identical to a 2D camera in a video game. Reading about “camera follow” scripts can provide a strong visual and conceptual analogy for your
scrollmethod.
Implement Downward Scrolling in a Terminal Text Editor
Mục tiêu: Add logic to the ‘scroll’ method to handle cases where the cursor moves to or below the bottom edge of the visible screen. This involves calculating the text area height and adjusting the ‘row_offset’ to scroll the viewport down, ensuring the cursor remains visible.
Excellent work! In the previous task, you taught your editor the first fundamental rule of scrolling: how to react when the user’s cursor moves above the visible area. That piece of logic is perfectly sound, lying in wait for its counterpart. You’ve built the top half of the ladder; now it’s time to build the bottom half so you can actually climb it.
Our current task is to implement the second, critical rule inside the scroll method: what to do when the cursor moves to or past the bottom edge of the screen. This is the final piece of logic that will unlock full vertical scrolling and truly liberate your editor from the confines of the physical terminal.
The Logic: Defining the “Bottom Edge”
Just as before, we must first define what the “bottom edge” means in terms of our editor’s state variables. This is slightly more complex than the top edge.
- Find the Text Area Height: Your terminal has
self.screen_rowsrows in total, but you have cleverly reserved the very last one for the status bar. This means the height of the area where text can actually be displayed isself.screen_rows - 1. - Calculate the First Invisible Line: We know the logical file line at the top of the screen is
self.row_offset. If we add the text area’s height to this offset, we get the logical file line number of the first row just below the visible screen. For example, if the offset is10and the text area height is24, then lines10through33are visible, and the first invisible line at the bottom is line34. - The Condition: The condition for the cursor being “off-screen at the bottom” is therefore:
self.cursor_y >= self.row_offset + text_area_height.
Once this condition is met, our reaction is to scroll the viewport down. The most intuitive user experience is to make the cursor’s line the new bottom line of the screen.
Implementing the Second Scrolling Rule
Let’s add this second if block to your scroll method. This will complete the vertical scrolling logic.
// src/main.rs -> inside the `impl Editor` block
/// Adjusts the `row_offset` to ensure the cursor is always within the
/// visible portion of the screen (the viewport).
fn scroll(&mut self) {
// This is the "scroll up" logic you've already implemented.
if self.cursor_y < self.row_offset {
self.row_offset = self.cursor_y;
}
// --- THIS IS THE NEW CODE FOR OUR TASK ---
// RULE 2: If the cursor is at or below the bottom of the visible window.
// First, we calculate the height of the text area. This is the total number
// of screen rows minus one for the status bar.
let text_area_height = self.screen_rows - 1;
// We check if the cursor's logical Y position has reached or surpassed the line
// that is just below the visible part of the screen.
if self.cursor_y >= self.row_offset + text_area_height {
// If it has, we've moved off the bottom of the screen.
// We scroll the view down by calculating a new `row_offset`. The goal is
// to make the cursor's line the new bottom line of the screen.
self.row_offset = self.cursor_y - text_area_height + 1;
}
// --- END OF NEW CODE ---
}
Code and Concept Deep Dive
Let’s dissect the new logic piece by piece:
let text_area_height = self.screen_rows - 1;: This is a crucial calculation that respects the UI partitioning you established when creating the status bar. It ensures our scrolling logic operates only within the text editing area.if self.cursor_y >= self.row_offset + text_area_height: This is the “detector” for scrolling down.self.row_offset + text_area_heightgives us the index of the first file row that is not visible at the bottom of the screen.- If the cursor’s
yposition is greater than or equal to this value, it’s officially out of view.
self.row_offset = self.cursor_y - text_area_height + 1;: This is the action that repositions the viewport. Let’s trace it with an example:- Assume
text_area_heightis24andcursor_ymoves to line24. - The
ifcondition24 >= 0 + 24is true. - The new
row_offsetbecomes24 - 24 + 1 = 1. - On the next screen refresh, the viewport will start rendering from file line
1. The cursor, at file line24, will be drawn at physical screen position24 - 1 = 23, which is the last line of the text area. It works perfectly.
- Assume
The Grand Reveal: True Vertical Scrolling
Congratulations! This is the moment where all the pieces of the viewport puzzle—the row_offset state, the modified draw_rows renderer, and now the complete scroll logic—come together.
Find a large text file (one with more lines than your terminal is tall). Open it with your editor: cargo run -- my_large_file.txt
Now, hold down the Down arrow key. As the cursor hits the bottom of the screen, the text will begin to scroll up smoothly. Now hold the Up arrow key. As the cursor reaches the top, the text will scroll back down. You have achieved true, seamless vertical scrolling.
Next Steps
Your editor has conquered the vertical dimension. However, it is still constrained horizontally. If a line of text is wider than your terminal, you have no way to see or edit the content that is off-screen to the right. The next logical step is to repeat the process you have just masterfully completed, but for horizontal scrolling. You will add a col_offset field and expand your scroll and rendering logic to manage it.
Further Reading
- Coordinate Systems in Computer Graphics: The logical (
cursor_y,row_offset) versus physical (yin the loop) coordinate system you’ve implemented is a core concept in all of computer graphics. Reading about it will provide a strong theoretical foundation for what you’ve built. - 2D Camera Follow Logic: The
scrollmethod you’ve built is conceptually identical to a “camera follow” script in a 2D video game. Watching a tutorial on this topic can provide an excellent visual analogy. - Integer Arithmetic in Rust: Reviewing Rust’s safe arithmetic operations is always a good idea, especially when dealing with offsets and subtractions that could potentially underflow.
Implement Horizontal Scrolling in a Text Editor
Mục tiêu: Implement horizontal scrolling by introducing a col\_offset to the editor’s state. This task involves updating the rendering logic to display the correct slice of each line and expanding the scrolling logic to adjust the viewport horizontally based on the cursor’s x-position.
An enormous congratulations on your last achievement! You have successfully implemented full vertical scrolling. By creating a row_offset and a scroll method that intelligently manages it, you have fundamentally liberated your editor from the vertical constraints of the terminal screen. You have mastered a powerful pattern: state -> render -> logic.
Now, we will apply this exact same pattern to conquer the horizontal dimension. Currently, if a line of text is wider than your terminal, the content on the right is simply cut off, invisible and inaccessible. Our current task is to implement horizontal scrolling, completing your viewport and giving you the freedom to navigate documents of any size, in any direction.
We will follow the exact three-step pattern you just used for vertical scrolling:
- Update State: Introduce a
col_offsetto track horizontal scroll. - Update View: Teach
draw_rowsto usecol_offsetto render the correct part of each line. - Update Logic: Expand the
scrollmethod to managecol_offsetbased on the cursor’sxposition.
Step 1: Update the Editor’s State
Just as row_offset tracks our “camera’s” vertical position, we need a new piece of state to track its horizontal position. This will be col_offset.
First, add the new field to your Editor struct.
// src/main.rs -> in the `Editor` struct definition
struct Editor {
// ... other fields
row_offset: usize,
// --- NEW FIELD ---
// Tracks which column of the file is at the left edge of the screen.
col_offset: usize,
}
Next, initialize it to 0 in your Editor::new() constructor. We always start at the beginning of a line.
// src/main.rs -> inside `Editor::new()`
fn new(filename: Option<String>) -> Self {
// ...
let mut editor = Self {
// ... other initializations
row_offset: 0,
// --- INITIALIZE THE NEW FIELD ---
// The editor always starts at the beginning of the line.
col_offset: 0,
};
// ...
editor
}
Step 2: Update the View (draw_rows)
This is the most significant change. Previously, draw_rows would print an entire line (or a truncated version of it). Now, it must print a slice of the line, starting from the col_offset.
We need to modify the else block within draw_rows to calculate and display the visible portion of each line based on the new col_offset.
// src/main.rs -> inside the `draw_rows` method
// ... inside the `for y in ...` loop
} else {
// --- THIS ENTIRE BLOCK IS REPLACED/MODIFIED ---
// Get a reference to the line to be rendered.
let row = &self.rows[file_row];
// Determine the start of the visible slice. It's our column offset.
let start = self.col_offset;
// Determine the end of the slice. It's the offset plus the screen width.
let end = self.col_offset.saturating_add(self.screen_cols);
// Slice the part of the row that should be visible on the screen.
// We handle two edge cases:
// 1. If the horizontal scroll (`start`) is past the end of the line, we show nothing.
// 2. We ensure the end of our slice (`end`) doesn't go past the actual end of the line.
let visible_part = if start >= row.len() {
"" // Display an empty string slice.
} else {
let end = std::cmp::min(end, row.len());
&row[start..end]
};
println!("{}\r", visible_part);
// --- END OF REPLACEMENT ---
}
// ...
Code Deep Dive: String Slicing
&row[start..end]: This is Rust’s powerful string slice syntax. It creates a view into a part of theString’s data without creating a new copy. This is highly efficient. It gives us a&strrepresenting the characters from thestartindex up to (but not including) theendindex.if start >= row.len(): A crucial safety check. If the user scrolls horizontally far beyond the end of a short line, we simply display nothing for that line.std::cmp::min(end, row.len()): Another key safety check. If the calculatedendof our slice is past the actual end of the string, this ensures we only slice up to the string’s true length, preventing a panic.
Step 3: Update the Logic (scroll)
Finally, we expand our scroll method to manage col_offset, perfectly mirroring the logic you already wrote for row_offset.
// src/main.rs -> inside the `scroll` method
fn scroll(&mut self) {
// Vertical scrolling logic (unchanged)
if self.cursor_y < self.row_offset {
self.row_offset = self.cursor_y;
}
let text_area_height = self.screen_rows - 1;
if self.cursor_y >= self.row_offset + text_area_height {
self.row_offset = self.cursor_y - text_area_height + 1;
}
// --- NEW HORIZONTAL SCROLLING LOGIC ---
// RULE 1: If the cursor is to the left of the visible window.
if self.cursor_x < self.col_offset {
// Scroll left to bring the cursor into view at the left edge.
self.col_offset = self.cursor_x;
}
// RULE 2: If the cursor is at or to the right of the visible window.
if self.cursor_x >= self.col_offset + self.screen_cols {
// Scroll right to bring the cursor into view at the right edge.
self.col_offset = self.cursor_x - self.screen_cols + 1;
}
// ------------------------------------
}
This new logic is a direct parallel to the vertical scrolling rules. It ensures that if the cursor moves off-screen to the left or right, the col_offset is adjusted to bring it back into the viewport.
The Complete Viewport
Congratulations! By applying the same trusted pattern to a new dimension, you have achieved full, two-dimensional scrolling.
Find a file with very long lines (or write some yourself!). When you open it (cargo run -- long_lines.txt), you can now use all four arrow keys to navigate freely. As you move the cursor past the right edge of the screen, the text will pan smoothly to the left. As you move back, it will pan to the right. Your editor is no longer a fixed window; it is a true camera, capable of exploring a document of any width or height.
Next Steps
There is one final, subtle task to complete this step. Your rendering and logic are perfect, but the physical cursor that blinks on the screen is still being positioned using absolute coordinates (cursor_x, cursor_y). This means if you scroll right, the physical cursor will try to move past the edge of the terminal, which is incorrect. Your final task is to update the physical cursor positioning logic in refresh_screen to subtract the offsets, translating logical coordinates to physical screen coordinates.
Further Reading
- Slicing in Rust (The Rust Book): A deep dive into how slices work for strings, arrays, and vectors. Understanding slices is key to writing efficient, idiomatic Rust.
std::cmp::minDocumentation: The official documentation for the simple but essential function you used to prevent slicing past the end of a string.- Coordinate Transformation: The concept of converting from “world” or “logical” coordinates (your text buffer) to “screen” or “physical” coordinates (the terminal) is a fundamental concept in all graphics programming. Reading about it will give you a strong theoretical foundation.
Synchronize Cursor Position with Viewport
Mục tiêu: Fix a rendering bug where the physical cursor does not follow horizontal and vertical scrolling. This is achieved by converting the cursor’s logical coordinates (its position in the file) to physical coordinates (its position on the screen) by subtracting the viewport’s row and column offsets before rendering.
An enormous congratulations are in order! You have just achieved something monumental. By adding the col_offset and expanding your scroll and draw_rows methods, you have successfully implemented full two-dimensional scrolling. You’ve followed a powerful pattern of updating state, then the view, then the logic, and in doing so, you’ve conquered the horizontal dimension. Your editor can now pan and scan across documents of any width or height.
However, if you’ve been testing this, you may have noticed one final, subtle bug. The text on the screen scrolls perfectly, but the physical, blinking cursor does not. When you scroll right, the cursor tries to move past the physical edge of your terminal, becoming invisible or getting stuck. Our final task in this step is to fix this by teaching the editor the difference between the cursor’s position in the file and its position on the screen.
The Final Piece: Logical vs. Physical Coordinates
This task is all about one fundamental concept in all of graphics and UI programming: the difference between logical coordinates and physical coordinates.
- Logical Coordinates (
cursor_x,cursor_y): This is the cursor’s absolute position within the entire text file. If your cursor is on the 500th line and the 90th character, thencursor_y = 499andcursor_x = 89. This is the “truth” of where the cursor is in the data model. - Physical Coordinates (screen row, screen column): This is where the blinking cursor needs to physically appear on the user’s terminal screen. If the user has scrolled down 490 lines (
row_offset = 490), their cursor on logical line 499 should appear on physical screen row9.
Your rendering logic in draw_rows has already mastered this conversion for the text itself. Now, we must apply the same conversion to the physical cursor. The formula is the one you’ve been working with all along:
physical_position = logical_position - offset
Updating the Physical Cursor Position
Let’s look at your refresh_screen method. Near the end, there is a single line responsible for placing the blinking cursor. It currently uses the cursor’s logical coordinates, which is the source of our bug.
// The old, incorrect line:
stdout.queue(cursor::MoveTo(self.cursor_x as u16, self.cursor_y as u16)).unwrap();
We will now modify this line to perform the crucial coordinate transformation, converting the logical cursor_x and cursor_y into physical screen coordinates before sending the command to crossterm.
Here is the updated refresh_screen method. The change is just a single, critical line.
// src/main.rs -> inside the `impl Editor` block
fn refresh_screen(&self) {
let mut stdout = stdout();
stdout.queue(cursor::Hide).unwrap();
stdout.queue(terminal::Clear(terminal::ClearType::All)).unwrap();
stdout.queue(cursor::MoveTo(0, 0)).unwrap();
self.draw_rows();
self.draw_status_bar();
// --- THIS IS THE CHANGE FOR OUR TASK ---
// Instead of using the cursor's absolute file coordinates, we now calculate
// its position relative to the viewport. This translates the logical
// coordinates of the cursor into physical coordinates on the screen.
stdout
.queue(cursor::MoveTo(
(self.cursor_x - self.col_offset) as u16,
(self.cursor_y - self.row_offset) as u16,
))
.unwrap();
// ------------------------------------
stdout.queue(cursor::Show).unwrap();
stdout.flush().unwrap();
}
Code and Concept Deep Dive
(self.cursor_y - self.row_offset): This is the vertical transformation. We take the cursor’s absolute line number in the file and subtract the number of lines we’ve scrolled down. The result is the physical row number on the screen where the cursor should appear.(self.cursor_x - self.col_offset): This is the horizontal transformation. We take the cursor’s absolute column number and subtract the number of columns we’ve scrolled to the right. This gives us the physical column number for the cursor.crossterm::cursor::MoveTo(col, row):crosstermexpects coordinates in(column, row)order, which maps to our(x, y)calculation. Theas u16casts are necessary as this is the coordinate typecrosstermexpects.- Safety: A direct subtraction here is safe. Your
scrollmethod’s logic already guarantees thatcursor_ywill never be less thanrow_offset, andcursor_xwill never be less thancol_offset, so this operation will not underflow.
Milestone Achieved: A True Viewport
This is the moment! Run your editor with a large, wide file (cargo run -- my_big_file.txt). Now, navigate in every direction. Scroll far down and far to the right. You will see that the blinking cursor is now perfectly synchronized with the text character it is on top of. It stays correctly positioned within the physical bounds of your terminal, no matter where your “camera” is pointed in the logical text buffer.
You have now fully and successfully completed the “Implement viewport scrolling” step. This is arguably one of the most complex and foundational features of any text editor, and you have mastered it. Your editor is no longer just a simple tool; it is a powerful application capable of handling documents of any size.
Next Steps
With a robust navigation and viewing system in place, you are perfectly positioned to build features that leverage it. The next great challenge on your roadmap is to implement text search functionality. When a user searches for text, you will not only need to find its logical coordinates (cursor_y, cursor_x) in the file but also use your scroll method to automatically pan the viewport so that the search result is brought directly into view for the user. Your work on scrolling has paved the way for this exciting new feature.
Further Reading
- Coordinate Systems and Transformations in Computer Graphics: The logical-to-physical conversion you just implemented is a fundamental concept known as a “coordinate transformation.” Reading about this will give you a strong theoretical foundation for UI and graphics programming.
- LearnOpenGL: Coordinate Systems (While 3D, the core concepts of transforming between different coordinate spaces are universal).
- The Model-View-Controller (MVC) Pattern Revisited: This entire step has been a masterclass in MVC. The
Editorstruct is your Model (holding state like offsets),draw_rowsis your View (rendering the state), and your main loop andscrollmethod are your Controller (updating the state based on input). Reflecting on how these pieces fit together will be invaluable. - Rust Type Casting: A good refresher on the
askeyword and how Rust handles numeric type conversions.