Optimizing My Code Editor with Flutter Profiling Tools

This article is the final installment in my series about building a custom code editor. In previous story I talked about how to use TwoWayScroll in a code editor. In this part, I’ll share how I used Flutter’s profiling tools to identify and resolve performance bottlenecks that were slowing down scrolling in my app. If you’ve been following along, you’ll see how a once sluggish editor demo evolved into a smooth and functional.........demo (lol). Introduction to Profiling Profiling sounded intimidating at first—I had never used it seriously before. But once I dove in, I found it surprisingly straightforward and incredibly helpful. Profiling allowed me to pinpoint exactly where my app was struggling and guided me toward effective optimizations. Enabling Profiling in Flutter To profile a Flutter desktop app, you don’t need to do anything special—just run the app in debug mode (no need for flutter run --profile). When running in debug mode, the DEBUG CONSOLE displays a line like this: Connecting to VM Service at ws://127.0.0.1:64769/WcZS3CufaRw=/ws This address connects your app to Flutter’s profiling tool. In VS Code, search for >Dart: Open DevTools in Browser, paste the VM service address into the "VM service URL" text box, and voilà—you’re connected to DevTools. The Performance tab shows the time taken for each Flutter frame, helping you identify slow or janky behavior. Enhancing Tracing for Detailed Insights For a more granular view, enable the following options in the "Enhance Tracing" dropdown: Track widget builds Track layouts Track paints This generates a detailed performance view (see Flutter’s documentation) that breaks down how long each activity takes and how smaller activities nest within larger ones. Identifying the Bottleneck With profiling tools enabled, I focused on identifying which part of my app was causing the slowdown. Slow Flutter frames are marked with a different color in the frame window. Clicking on a specific frame reveals the corresponding timeline, where you can drill down into the exact activities causing delays. Pro Tip: Press Shift to move horizontally across the timeline, and Ctrl + scroll to zoom in and out. The Culprit: LineWidget The profiling data revealed that LineWidget was the primary bottleneck. Each LineWidget took a long time during the build phase, as it rendered individual characters or environment variables as RuneWidget or VarWidget. This meant that scrolling through large files required rebuilding hundreds or thousands of widgets, which was far from efficient. # TODO: Show how slow `LineWidget` is Optimizing LineWidget To improve performance, I needed to reduce the number of widgets used to render text. Here’s how I approached the optimization: Handling Lines Without Environment Variables I noticed that many large files don’t contain environment variables. For such lines, I created a new build function that packs all characters into a single RuneWidget and assigns a color. This drastically reduced the number of widgets and improved scrolling performance. Cursor and Selection Highlighting For cursor and selection highlighting, I replaced invisible widgets with floating colored blocks positioned behind and above the text. This approach required only two extra widgets per line and some mathematical calculations, eliminating the need for complex RichText manipulations. Here’s the optimized build logic: @override Widget build(BuildContext context) { for (Rune r in runes) { if (r.isVar) { hasVar = true; break; } } if (hasVar) { return buildWithVar(context); // Original build process } return buildPureText(context); // Optimized build process } Widget buildPureText(BuildContext context) { double chWidth = chSize.width; int blinkCursorIndex = -1; if (widget.cursorManager.lineIndex == widget.lineIdx) { blinkCursorIndex = widget.cursorManager.runeIndex; } List textRowChildren = []; var strBuffer = StringBuffer(); Rune? prev; if (runes.isNotEmpty) { prev = runes.first; } // Separate text into `RuneWidget` based on colors for (int i = 0; i maxCursorPosition) { cursorPosition = maxCursorPosition; } if (cursorPosition -1 ) ) ); } return Padding( padding: const EdgeInsets.only(left: -Configuration.clickOffset), child: RepaintBoundary( child: Container( color: null, child: Stack(children: stackChildren), ), ), ); } In the buildPureText function, I grouped text by color and calculated the selection box length and cursor position using the character size (since the editor uses a monospace font). The widgets were then stacked using a Stack widget, which renders its children in order. Results This optimization significantly reduced the build time for LineWidget. While refreshing the entire line wh

Mar 26, 2025 - 12:00
 0
Optimizing My Code Editor with Flutter Profiling Tools

This article is the final installment in my series about building a custom code editor. In previous story I talked about how to use TwoWayScroll in a code editor.

In this part, I’ll share how I used Flutter’s profiling tools to identify and resolve performance bottlenecks that were slowing down scrolling in my app. If you’ve been following along, you’ll see how a once sluggish editor demo evolved into a smooth and functional.........demo (lol).

Introduction to Profiling

Profiling sounded intimidating at first—I had never used it seriously before. But once I dove in, I found it surprisingly straightforward and incredibly helpful. Profiling allowed me to pinpoint exactly where my app was struggling and guided me toward effective optimizations.

Enabling Profiling in Flutter

To profile a Flutter desktop app, you don’t need to do anything special—just run the app in debug mode (no need for flutter run --profile). When running in debug mode, the DEBUG CONSOLE displays a line like this:

Connecting to VM Service at ws://127.0.0.1:64769/WcZS3CufaRw=/ws

This address connects your app to Flutter’s profiling tool. In VS Code, search for >Dart: Open DevTools in Browser, paste the VM service address into the "VM service URL" text box, and voilà—you’re connected to DevTools. The Performance tab shows the time taken for each Flutter frame, helping you identify slow or janky behavior.

Enhancing Tracing for Detailed Insights

For a more granular view, enable the following options in the "Enhance Tracing" dropdown:

  • Track widget builds
  • Track layouts
  • Track paints

This generates a detailed performance view (see Flutter’s documentation) that breaks down how long each activity takes and how smaller activities nest within larger ones.

Identifying the Bottleneck

With profiling tools enabled, I focused on identifying which part of my app was causing the slowdown. Slow Flutter frames are marked with a different color in the frame window. Clicking on a specific frame reveals the corresponding timeline, where you can drill down into the exact activities causing delays.

Pro Tip: Press Shift to move horizontally across the timeline, and Ctrl + scroll to zoom in and out.

The Culprit: LineWidget

The profiling data revealed that LineWidget was the primary bottleneck. Each LineWidget took a long time during the build phase, as it rendered individual characters or environment variables as RuneWidget or VarWidget. This meant that scrolling through large files required rebuilding hundreds or thousands of widgets, which was far from efficient.

# TODO: Show how slow `LineWidget` is

Optimizing LineWidget

To improve performance, I needed to reduce the number of widgets used to render text. Here’s how I approached the optimization:

Handling Lines Without Environment Variables

I noticed that many large files don’t contain environment variables. For such lines, I created a new build function that packs all characters into a single RuneWidget and assigns a color. This drastically reduced the number of widgets and improved scrolling performance.

Cursor and Selection Highlighting

For cursor and selection highlighting, I replaced invisible widgets with floating colored blocks positioned behind and above the text. This approach required only two extra widgets per line and some mathematical calculations, eliminating the need for complex RichText manipulations.

Here’s the optimized build logic:

@override
Widget build(BuildContext context) {
  for (Rune r in runes) {
    if (r.isVar) {
      hasVar = true;
      break;
    }
  }
  if (hasVar) {
    return buildWithVar(context); // Original build process
  }
  return buildPureText(context);  // Optimized build process
}

Widget buildPureText(BuildContext context) {
  double chWidth = chSize.width;
  int blinkCursorIndex = -1;
  if (widget.cursorManager.lineIndex == widget.lineIdx) {
    blinkCursorIndex = widget.cursorManager.runeIndex;
  }

  List<Widget> textRowChildren = [];
  var strBuffer = StringBuffer();
  Rune? prev;
  if (runes.isNotEmpty) {
    prev = runes.first;
  }

  // Separate text into `RuneWidget` based on colors
  for (int i = 0; i < runes.length; i++) {
    Rune r = runes[i];
    if (r.color?.value != prev!.color?.value) {
      var str = strBuffer.toString();
      textRowChildren.add(
        Stack(
          alignment: Alignment.centerLeft,
          children: [
            RuneWidget(
              key: UniqueKey(),
              isSelected: false,
              ch: str,
            ),
          ],
        ),
      );
      strBuffer.clear();
    }
    strBuffer.write(r.ch);
    prev = r;
  }

  if (strBuffer.isNotEmpty) {
    var str = strBuffer.toString();
    textRowChildren.add(
      RuneWidget(
        key: UniqueKey(),
        isSelected: false,
        ch: str,
      ),
    );
    strBuffer.clear();
  }

  // Handle end of line highlight for selection
  bool isLineSelected = widget.cursorManager.isEndOfLineWithinSelection(widget.lineIdx);
  Selection? selection = widget.cursorManager.computeSelectionForLine(widget.lineIdx);
  double selectionStartOffset = 0;
  double selectionWidth = 0;
  if (selection != null) {
    double maxSelectionWidth = chWidth * (runes.length + 0.5) * strangeCoefficient;
    int end = selection.end;
    if (end == -1) {
      end = runes.length;
    }
    selectionStartOffset = chWidth * selection.start * strangeCoefficient;
    selectionWidth = chWidth * (end - selection.start) * strangeCoefficient;
    if (selectionWidth < 0) {
      selectionWidth = 0;
    }
    if (selectionWidth > maxSelectionWidth) {
      selectionWidth = maxSelectionWidth;
    }
  }
  if (isLineSelected) {
    selectionWidth += chWidth / 2;
  }

  Widget textRow = ConstrainedBox(
    constraints: BoxConstraints(
      minWidth: chSize.width,
    ),
    child: Row(
      key: _rowKey,
      mainAxisAlignment: MainAxisAlignment.start,
      mainAxisSize: MainAxisSize.max,
      crossAxisAlignment: CrossAxisAlignment.stretch,
      children: textRowChildren,
    ),
  );

  // Selection color layer and text layer
  List<Widget> stackChildren = [
    Positioned(
      left: selectionStartOffset,
      child: DecoratedBox(
        decoration: const BoxDecoration(color: Configuration.highlightColor),
        child: SizedBox(width: selectionWidth, height: chSize.height),
      )
    ),
    textRow,
  ];

  // If cursor is in this line, add it to the stack
  if (widget.cursorManager.lineIndex == widget.lineIdx) {
    double maxCursorPosition = chWidth * strangeCoefficient * runes.length - Configuration.cursorWidth / 2;
    double cursorPosition = chWidth * blinkCursorIndex * strangeCoefficient - Configuration.cursorWidth / 2;
    if (cursorPosition > maxCursorPosition) {
      cursorPosition = maxCursorPosition;
    }
    if (cursorPosition < 0) {
      cursorPosition = 0;
    }
    stackChildren.add(
      Positioned(
        left: cursorPosition,
        child: CursorSlot(
          key: UniqueKey(),
          events: widget.eventManager,
          controller: cursorControllers[0],
          defaultBlink: blinkCursorIndex > -1
        )
      )
    );
  }

  return Padding(
    padding: const EdgeInsets.only(left: -Configuration.clickOffset),
    child: RepaintBoundary(
      child: Container(
        color: null,
        child: Stack(children: stackChildren),
      ),
    ),
  );
}

In the buildPureText function, I grouped text by color and calculated the selection box length and cursor position using the character size (since the editor uses a monospace font). The widgets were then stacked using a Stack widget, which renders its children in order.

Results

This optimization significantly reduced the build time for LineWidget. While refreshing the entire line whenever the cursor position updates isn’t ideal, profiling showed that the overall performance improved dramatically.

(Before optimization, scrolling was a bit glichy, heavily depending on your desktop or laptop actually...)

(Almost as smooth as vscode on my testing machine: MacOS air m1 and Dell i5 optiplex)

(Well on my new laptop I actually cannot see any difference...I would not even optimize this if I had better equip...)

Drawbacks and Future Improvements

While the optimization worked well, it came with a few trade-offs:

  1. Font Limitations: The approach only works with monospace fonts, so I mandated the font style in the editor.
  2. Scaling Quirks: A mysterious scaling factor was required to align the selection box with the text. This factor might need adjustment for different font sizes.

Despite these issues, the optimization made the editor usable for browsing large files without performance problems.

Reflections

Looking back, I didn’t have a clear, logically provable path to optimize LineWidget. I experimented with various ideas, chose the easiest ones, and tested them. If they worked, I adopted them. Perhaps with more Flutter expertise, I could have found even better solutions.

If you’ve followed this series, you’ve seen how a poorly performing demo evolved into a functional code editor. The code I shared is simplified, but it reflects the progression from a non-workable prototype to a usable tool. The app isn’t perfectly planned or cleanly implemented—it’s full of historical debts and quick decisions. Yet, I’m proud of it. I had fun building it, experimenting, and gradually improving it. I use it almost daily, and it helps me with my work.

Building something doesn’t require theoretical assurance. It’s an ongoing process of recognizing and solving problems. Over time, your efforts can lead to a level of perfection.

Real-World Use Cases

In my daily work, I’ve found my app particularly useful in the following scenarios:

1. curl Commands

This was the original inspiration for the app. I use curl commands to test RESTful APIs, and the ability to mark variables in different profiles allows me to make new requests by simply switching profiles or updating variable values—no more manual replacements.

2. CloudWatch Queries

Debugging customer issues often involves running a routine set of log queries. While CloudWatch saves commonly used queries, they aren’t parameterized. With my app, I can define a customerId variable and update all related queries in one go—no more losing permissions or manually editing queries.

3. kubectl Queries

kubectl commands can be long and repetitive, especially when typing pod names or namespaces. My app saves me time by letting me define variables for these values, eliminating the need to type them repeatedly.

4. SQL Queries

Similar to CloudWatch queries, SQL queries can be parameterized to avoid typing long text or replacing values manually. While my app doesn’t handle complex SQL operations like adding or removing WHERE conditions, it’s still a handy tool for simpler tasks.

Take a look if you are interested in the final form of my code editor

Final Thoughts

This journey has been both challenging and rewarding. I started with a simple idea, faced numerous obstacles, and gradually improved the app through experimentation and optimization. While it’s not perfect, it’s a tool I use daily, and it’s made my work easier. I hope to share it with a broader audience and maybe even generate some financial return. But even if that doesn’t happen, I’ve accomplished something valuable.

Building something doesn’t require a perfect plan—it’s about solving problems one step at a time. Over time, those steps add up, and you end up with something you can be proud of.