This content originally appeared on DEV Community and was authored by Jack Chen
In this article, I’ll walk you through how my code editor evolved into a more robust and efficient tool. In the previous article, I attempted to handle user input and text rendering at the character level. While this approach worked initially, it quickly became clear that it wasn’t scalable or maintainable. To address these issues, I decided to shift the abstraction level from individual characters to lines and documents. Additionally, I leveraged a new Flutter component (which had just been released at the time) to enable two-way scrolling—a feature I’ll cover in the next section.
Disclaimer
As I reviewed my code, I realized that I had initially focused on developing minor functionalities—like opening files and handling file trees—before addressing the core problem: building a performant and user-friendly code editor. To keep this article focused, I won’t delve into those side features. Instead, I’ll concentrate on the key improvements that transformed my app.
The Need for a Better Widget
The biggest flaw in my initial design was handling user input at the character level. This approach led to performance bottlenecks and made the code difficult to maintain. To fix this, I introduced a new widget called DocumentWidget
. This widget would handle all user input, detect where the user clicked, and use math to determine which line and character were being interacted with.
Here’s the new structure:
-
DocumentWidget
: Handles user input and manages the overall document. -
LineWidget
: Represents a single line of text. -
RuneWidget
: Now aStatelessWidget
, representing a single character. -
VarWidget
: AStatefulWidget
for environment variables. -
CursorSlot
: A dedicated widget for handling cursor blinking.
By moving the abstraction level up to lines and documents, I could simplify the logic and improve performance.
Improvements in Widget Design
The refactored design separates concerns more clearly:
-
RuneWidget
is now aStatelessWidget
, making it lightweight and focused solely on rendering characters. -
VarWidget
remains aStatefulWidget
to handle dynamic updates for environment variables. -
CursorSlot
encapsulates the logic for cursor blinking, ensuring that only one cursor blinks at a time.
Here’s how a LineWidget
is structured:
- A
CursorSlot
at the beginning. - A series of
RuneWidget
s orVarWidget
s. - Another
CursorSlot
at the end.
When the user clicks on a character, the corresponding CursorSlot
starts blinking, and the previously active one stops. This is achieved using a StreamController
:
- When a
CursorSlot
(let’s call it A) starts blinking, it emits aCursorClickEvent
and subscribes to the same event with a callback to stop blinking. - If another
CursorSlot
(B) starts blinking, it emits its ownCursorClickEvent
, causing A to stop blinking and unsubscribe. - This ensures that only one
CursorSlot
is active at any time, preventing performance issues as the document grows.
The Better Implementation: Controller Pattern
To manage communication between DocumentWidget
and LineWidget
, I initially considered using events. However, this approach would have required defining numerous event types or creating a single event class with multiple payloads, leading to messy and hard-to-maintain code. Instead, I adopted the controller pattern.
Here’s how it works:
-
LineController
: A class that exposes methods for tasks like triggering cursor blinking, highlighting text, and updating content. -
LineWidget
: Accepts aLineController
as a parameter and initializes it in theinitState
method.
class LineController {
bool ready = false;
late Function triggerCursorBlink;
late Function cancelCursorBlink;
late Function highlightText;
// Add more functions as needed...
}
class LineWidget extends StatefulWidget {
final LineController controller;
const LineWidget({
required this.controller,
super.key
});
@override
State<LineWidget> createState() => _LineWidgetState();
}
class _LineWidgetState extends State<LineWidget> {
@override
void initState() {
_initController();
super.initState();
}
void _initController() {
if (widget.controller.ready) return;
widget.controller.triggerCursorBlink = () {
// Logic to start cursor blinking
};
widget.controller.cancelCursorBlink = () {
// Logic to stop cursor blinking
};
widget.controller.ready = true;
}
@override
Widget build(BuildContext context) {
_initController();
// Build the widget tree
}
}
Pitfalls of the Controller Pattern
While the controller pattern simplified communication, it introduced its own challenges:
- Initialization Issues: The parent widget might invoke functions before the child widget initializes them.
- Widget Disposal: If the child widget is disposed, calling its functions could lead to errors.
-
Redundant Checks: To avoid these issues, I added a
ready
flag and numerousmounted
checks, which made the code feel repetitive.
Despite these drawbacks, the controller pattern made the code easier to trace and debug compared to an event-driven approach.
Testing the New Design
To validate the new design, I added basic text manipulation features like inserting and deleting characters. While these features worked well, I realized that LineWidget
was becoming overly complex. To keep the code clean, I decided to move text manipulation logic into a separate module. This way, LineWidget
could focus solely on rendering and user interaction.
Reflections on the Refactoring Process
This refactoring journey taught me an important lesson: it’s okay to make mistakes. As a junior developer (despite my “senior” title), I was used to working with well-defined abstractions and rarely encountered major design flaws. But when I started building my own app, I made plenty of mistakes—and that’s okay. Refactoring didn’t erase all my previous work; instead, it helped me evolve the app into a better version.
While the final implementation isn’t perfect, it’s a significant improvement over the initial design. And most importantly, it works!
This is a fantastic continuation of your journey! You’ve done a great job explaining the technical challenges and your thought process as you tackled two-way scrolling and performance optimization. Below, I’ve refined and polished this section to make it more engaging and easier to follow. I’ve also suggested a title for this part of your article.
Two-Way Scrolling and Its Optimization**
In this section, I’ll dive into how I implemented two-way scrolling in my code editor and the performance challenges I faced along the way. While the initial implementation worked, it wasn’t scalable—opening large files revealed significant performance bottlenecks. Here’s how I tackled these issues and what I learned in the process.
The Inspiration for Two-Way Scrolling
I was inspired by this Flutter code editor tutorial. However, one problem the tutorial didn’t address was two-way scrolling. In the tutorial, lines of code wrap instead of overflowing and being hidden. Initially, I thought using two SingleChildScrollView
s (one for horizontal scrolling and one for vertical scrolling) would solve the problem. Unfortunately, this approach had a major flaw: the scrollbars were rendered at the end of their parent widget, meaning users had to scroll all the way to the bottom to see the horizontal scrollbar or all the way to the right to see the vertical scrollbar. This was far from ideal.
Enter TwoDimensionalScrollView
Fortunately, the Flutter team released TwoDimensionalScrollView
, a widget designed specifically for two-way scrolling. This widget supports virtual rendering, meaning only the visible portion of the content is rendered on the screen. While this sounded perfect, there was a catch: no one had posted a tutorial on how to use TwoDimensionalScrollView
for rendering text. The official example (DartPad link) only demonstrated rendering fixed-size blocks, which didn’t help with variable-width text lines.
A Naive First Attempt
My initial approach was to treat the entire editor space as a single block. This meant there was only one vicinity (the visible area), and it worked—sort of. While this solved the two-way scrolling problem, it wasn’t scalable. Rendering the entire document at once caused significant performance issues, especially with large files.
The Performance Problem
After months of adding features like a file explorer, keyboard controls, and a terminal (thanks to TerminalStudio’s open-source code), my app started to take shape. However, when I tested it with a 1,000-line file, the performance issues became glaringly obvious:
- Slow Scrolling: Scrolling through large files was sluggish.
- Delayed Line Insertions: Inserting a new line in a large file took noticeable time because the entire document had to be re-rendered.
This was unacceptable. My app wasn’t yet usable for real-world tasks, and I needed to find a better solution.
(This example shows insertion is a bit slow, but if your computer is very good, you might not be able to notice anything)
Optimizing with Virtual Rendering
The root cause of the performance issues was clear: rendering the entire document in a single TwoDimensionalScrollView
vicinity was inefficient. To fix this, I needed to implement virtual rendering for individual lines. Here’s how I approached it:
-
Dynamic Line Height and Width:
- Unlike the fixed-size blocks in the official example, text lines have variable widths.
- I used Flutter’s
RenderBox
API to dynamically calculate the width of each line usinggetMaxIntrinsicWidth
. While the documentation warns that this method is expensive, it was necessary for accurate rendering.
-
Rendering Only Visible Lines:
- I calculated the visible range of lines based on the scroll position and viewport dimensions.
- Only the lines within this range were rendered, significantly reducing the workload.
-
Refactoring the Code:
- I refactored the
layoutChildSequence
method to handle dynamic line heights and widths. - This involved calculating the layout offset for each line and ensuring the scroll extents were updated correctly.
- I refactored the
Here’s a simplified version of the key logic:
@override
void layoutChildSequence() {
final double verticalPixels = verticalOffset.pixels;
final double viewportHeight = viewportDimension.height + cacheExtent;
// Calculate line height (fixed for all lines)
ChildVicinity firstRune = const ChildVicinity(xIndex: 1, yIndex: 0);
final RenderBox firstRuneRB = buildOrObtainChildFor(firstRune)!;
double lineHeight = firstRuneRB.getMaxIntrinsicHeight(double.maxFinite);
double width = firstRuneRB.getMaxIntrinsicWidth(double.maxFinite);
firstRuneRB.layout(BoxConstraints(minHeight: lineHeight, maxHeight: lineHeight, minWidth: width, maxWidth: width));
parentDataOf(firstRuneRB).layoutOffset = Offset(lineNumberWidth, -verticalOffset.pixels);
// Determine visible lines
int leadingRow = math.max((verticalPixels / lineHeight).floor(), 0);
int tailingRow = math.min(((verticalPixels + viewportHeight) / lineHeight).ceil(), maxRowIndex);
if (tailingRow <= leadingRow) {
tailingRow = leadingRow + 1;
}
// Render visible lines
double yLayoutOffset = (leadingRow * lineHeight) - verticalOffset.pixels;
for (int i = leadingRow; i < tailingRow; i++) {
final ChildVicinity vicinity = ChildVicinity(xIndex: 1, yIndex: i);
final RenderBox child = (i == 0) ? firstRuneRB : buildOrObtainChildFor(vicinity)!; // the same renderbox cannot be 'built' more than once.
double width = child.getMaxIntrinsicWidth(double.maxFinite) + 100;
child.layout(BoxConstraints(minHeight: lineHeight, maxHeight: lineHeight, minWidth: width, maxWidth: width));
parentDataOf(child).layoutOffset = Offset(-horizontalOffset.pixels, yLayoutOffset); // this is actually the result of trial and error...never wrapped my head around any offset numbers...
yLayoutOffset += lineHeight;
}
// Update scroll extents, these make sure scrollbar behave correctly
final double verticalExtent = lineHeight * (maxRowIndex + 1);
verticalOffset.applyContentDimensions(0.0, clampDouble(verticalExtent - viewportDimension.height, 0.0, double.infinity));
horizontalOffset.applyContentDimensions(0.0, clampDouble(maxWidth - viewportDimension.width, 0.0, double.infinity));
}
(This version is much more responsive, at least on my MacOS m2...)
The Results
After implementing these optimizations, the performance improved significantly:
- Faster Line Insertions: Inserting a new line in a large file no longer caused noticeable delays.
- Smoother Scrolling: Scrolling through large files became much more responsive.
However, there was still room for improvement. Scrolling quickly sometimes caused glitches, and the app still couldn’t match the smoothness of editors like VSCode.
The Final Challenge: Profiling
Despite the optimizations, the app wasn’t yet perfect. To identify the remaining bottlenecks, I knew I needed to dive into profiling. This would help me pinpoint the exact causes of the performance issues and optimize further.
Next Steps
In the next article, I’ll share how I used Flutter’s profiling tools to diagnose and fix the remaining performance issues. Stay tuned for the final chapter in this journey!
In case anyone is interested, github links are available:
before optimization
after optimization
The final product I made is SourcemanEditor, allows you to switch workspace environment profiles in a simple click, making multi-regional work easier. I personally used it for saving my debug queries, hopefully it can help you and make your daily routines a bit easier as well!
This content originally appeared on DEV Community and was authored by Jack Chen

Jack Chen | Sciencx (2025-03-17T19:35:51+00:00) From Characters to Lines: Refactoring My Code Editor for Better Performance and Two-Way Scrolling. Retrieved from https://www.scien.cx/2025/03/17/from-characters-to-lines-refactoring-my-code-editor-for-better-performance-and-two-way-scrolling/
Please log in to upload a file.
There are no updates yet.
Click the Upload button above to add an update.