How I Created a Small Tool That Made My Work Easier

As a Node.js backend API developer, my primary responsibility is building RESTful APIs for our system. These APIs serve various teams within my company and assist colleagues who handle customer-reported bugs. When I’m not developing APIs, I’m often using them to troubleshoot issues—querying or modifying data in the backend. While there are countless API testing tools available, and most are user-friendly, my specific needs led me down a unique path.

My company operates globally, with services deployed across multiple regions. However, our data isn’t centralized, meaning the same server code runs in different regions worldwide. Whenever I need to investigate an issue, I have to adjust the parameters of my query collections to match the region I’m targeting. Free API testing tools work well enough for this, but they come with limitations.

One day, disaster struck. I was prompted to upgrade my API testing tool, and the update completely corrupted my saved work. Even reinstalling the old version didn’t help — my query collections were gone forever. Frustrated and determined to avoid a repeat, I decided to create my own tool.

The Problem with Existing Tools

Making API requests isn’t inherently complicated. Modern GUI-based tools let you send requests with a simple click of a “POST” button. In the past, we relied on curl commands in the terminal, specifying parameters manually. While functional, this approach lacked flexibility. I needed a way to parameterize my curl commands to work seamlessly across different regions.

For example, to test a server in the US, I’d use:
curl -X GET https://us.company.com/test

For Europe, the command would be:
curl -X GET https://eu.company.com/test

In my previous testing tool, I parameterized the hostname. This was straightforward: I wrote a Python script to substitute placeholders in my curl commands with actual values. For instance, the script would take a command like curl -X GET {{hostname}}/test and replace {{hostname}} with the appropriate value from an environment file.

Here’s how it worked:

  1. I stored the curl command in a file called curl.txt.

  2. I created environment files like env.us (with hostname=https://us.company.com) and env.eu (with hostname=https://eu.company.com).

  3. Running python generate.py curl.txt env.us would generate the correct command, which I could then copy and execute in the terminal.

This approach allowed me to save and organize as many files as I needed. At first, I was satisfied with this solution.

The Limitations of My Initial Solution

However, I quickly grew tired of typing python generate.py every time I needed a curl command. As the number of command files grew, organizing them into folders became cumbersome. I often found myself switching between running the Python script and executing the generated commands, sometimes accidentally re-running an old query because I forgot to copy the new one.

API testing tools have one undeniable advantage: clicking is simpler. At least, it is for me.

I wondered if there was a smarter way to handle this process. In Linux, the source command can change the execution environment of a script. I considered turning my command files into shell scripts and replacing {{hostname}} with $hostname. Then, I could use source us.env to switch environments and run the script directly.

While this was a viable solution, it didn’t eliminate the need to type out the correct shell script to execute. It was still cumbersome compared to the simplicity of clicking buttons in a GUI.

The Birth of My App Idea

I wanted a tool that would let me click and get results. I was willing to spend time setting things up initially, but I didn’t want to type anything afterward. Sure, I could have searched for another tool that wouldn’t delete my data, but I saw an opportunity to solve a problem I cared about. Isn’t that the essence of being a software developer?

So, I decided to build my own app.

As a developer, I wanted this tool to be versatile. I asked myself, “What if, in the future, I work in another multi-regional company where I need to create templating scripts for tasks beyond curl commands?” I didn’t want just an API testing tool; I needed something that could handle parameter substitution with a simple click and execute commands seamlessly.

The idea came naturally: everything is a file. Whether you’re working with code, scripts, or configurations, you’re always dealing with files. So, I envisioned a text editor that could switch parameter values with a single click.

Here’s how I imagined it:

  • The curl.txt file would still contain curl -X GET {{hostname}}/test.

  • The text editor would recognize {{hostname}} as a placeholder and render it as editable text.

  • The name and value of {{hostname}} would be displayed in a side panel alongside other environment variables.

(Since the app is already developed, here is what it looks like...)

I wanted the app to resemble VSCode because I’m familiar with it and often ran my generate.py script there. The right-side panel would display all environment files—essentially a list of profiles with variables that could be substituted into any file being edited.

Choosing the Right Technology

First, I needed to learn how to build a code editor. While I could have used JavaScript, I didn’t want to deal with Electron or WebView. Plus, side projects are a great way to learn new languages. So, I chose Flutter.

Flutter uses Dart, which is easy to pick up, and it offers multi-platform support. This meant my app could run on macOS and Windows with minimal changes, and it could even be adapted for the web.

Thanks to this tutorial, I learned the basics of creating a code editor. Before diving in, I assumed there were complex algorithms or data structures for reading, displaying, and storing file content. However, the tutorial revealed that characters are simply read, displayed, and written one by one. Even when syntax highlighting is involved, each character is processed individually. While there might be more efficient algorithms for handling large files, I didn’t explore them further at this stage.

First Attempt to Write a Code Editor

Writing a code editor felt intimidating at first. Of course, I didn’t expect to create something as sophisticated as VSCode right away, so I decided to borrow some ideas from the tutorial I mentioned earlier. While directly copying the tutorial might have been a more efficient engineering choice, it would have taken away the fun of building something from scratch!

The main difference in my code editor was how I wanted to handle environment variables. I envisioned treating them like special characters that could display their corresponding values based on the selected environment profile. This meant that reading a file character by character wouldn’t suffice—I needed a way to distinguish between normal characters and environment variables.

To achieve this, I implemented a simple rule: any text pattern matching $<10 random characters> would be recognized as an environment variable. When a file is opened, its content is read character by character. If the special pattern is encountered, it’s decoded as the key for the environment variable. I called each character a Rune, which could either represent a normal character or an environment variable.

Here’s the code for the Rune class and the parsing logic:

class Rune {
  bool isVar;
  String? ch;
  String? varKey;

  Rune({required this.isVar, this.ch, this.varKey});
}

List<Rune> parseline(String line) {
  List<Rune> parsed = [];
  ParseMode mode = ParseMode.text;
  List<int> varCodes = [];
  line.runes.forEach((r) {
    if (mode == ParseMode.text) {
      if (r == magicCharacter) {
        mode = ParseMode.variable;
        return;
      }
      String ch = String.fromCharCode(r);
      Rune rune = Rune(isVar: false, ch: ch);
      parsed.add(rune);
    } else if (mode == ParseMode.variable) {
      bool discardFlag = false;
      if (varCodes.length >= magicVarLength) {
        if (r != magicCharacter) {
          discardFlag = true;
        }
      } else {
        varCodes.add(r);
        return;
      }
      if (discardFlag) {
        String ch = String.fromCharCode(magicCharacter);
        Rune firstRune = Rune(isVar: false, ch: ch);
        parsed.add(firstRune);
        for (int v in varCodes) {
          ch = String.fromCharCode(v);
          Rune rune = Rune(isVar: false, ch: ch);
          parsed.add(rune);
        }
        ch = String.fromCharCode(r);
        Rune rune = Rune(isVar: false, ch: ch);
        parsed.add(rune);
      } else {
        print("found var");
        String key = String.fromCharCodes(varCodes);
        Rune rune = Rune(isVar: true, varKey: key);
        parsed.add(rune);
      }
      mode = ParseMode.text;
    }
  });
  return parsed;
}

Managing Profiles and Environment Variables

Another key feature of my code editor was the right-hand-side panel, which displays all environment profiles. Each profile contains a list of environment variables, and when a profile is selected, the currently opened file should update all its variables to reflect the corresponding values.

To manage the relationship between profiles and environment variables, I created two classes: ProfileManager and EnvVarManager. These classes handle loading, reading, writing, and saving profiles and environment variables. Each manager operates as a singleton during the app’s runtime, acting like an in-memory key-value store for all profiles and environment variables. Each profile and environment variable has a unique key, which can be retrieved from these managers.

Event-Driven Communication

In Flutter, there are several ways to handle message passing between widgets and components. Most tutorials recommend using Provider for shared context, but Provider is hierarchical, and I wasn’t sure if it would be sufficient for a code editor. Instead, I opted for a more generic (albeit slightly dirtier) approach: event-driven communication.

I created an EventManager using StreamController to broadcast messages. Here’s the implementation:

class EventManager {
  final _controllers = <int, dynamic>{};

  StreamController<T> _getStreamController<T> () {
    int typeCode = T.hashCode;
    if (_controllers.containsKey(typeCode)) {
      return _controllers[typeCode];
    }
    StreamController<T> streamController = StreamController<T>.broadcast();
    _controllers[typeCode] = streamController;
    return streamController;
  }

  emit<T> (T event) {
    _getStreamController<T>().add(event);
  }

  StreamSubscription listen<T> (Function callback) {
    var streamController = _getStreamController<T>();
    var subscription = streamController.stream.listen((T event) {
      Function.apply(callback, [event]);
    });
    return subscription;
  }
}

With this setup, I could define any events I needed. Widgets or components that required messages from elsewhere could listen to specific event types. The EventManager instance was passed down to child widgets as a parameter, enabling seamless communication across the app.

Rendering a Rune

With the logic for handling data in place, the next step was to figure out how to render each Rune and enable dynamic updates for environment variable values.

Observing Code Editor Behavior

I started by observing the behavior of a typical code editor. At its core, a code editor has two fundamental features:

  1. A blinking cursor.

  2. The ability to select text, with selected text highlighted.

Of course, code editors have many more advanced features, but I wanted to start with the basics and evolve the design over time. For example, syntax highlighting is an important feature, but I decided to address it later since it wasn’t critical to my core idea.

Building the RuneWidget

To get something visible quickly, I implemented these basic requirements in a StatefulWidget called RuneWidget. Each RuneWidget represents either a character or an environment variable. It needed to be stateful to handle the blinking cursor animation.

Each character is essentially a box containing a string. If the cursor is on a character, the left border of the box blinks, creating the illusion of a cursor. The RuneWidget can also be individually styled with different background and text colors. When an environment variable is present, the RuneWidget displays the variable’s value, which can be modified in the right sidebar.

Here’s the implementation of the RuneWidget:

class RuneWidget extends StatefulWidget {
  final EventManager eventManager;
  final bool isEnvVariable;
  final String text; // if isEnvVariable is true, text is the key of env variable
  const RuneWidget({
    required this.eventManager,
    required this.isEnvVariable,
    required this.text,
    super.key
  });

  @override
  State<RuneWidget> createState() => _RuneWidgetState();
}

class _RuneWidgetState extends State<RuneWidget> {
  // I was too lazy to define another class...
  static Color highlightColor = const Color.fromARGB(255, 110, 147, 226);
  static TextStyle defaultStyle = const TextStyle(fontFamily: 'FiraCode', fontSize: 18, color: Color(0xff272822));
  static Border cursorBorder = const Border(left: BorderSide(width: 0.8, color: Colors.black,));
  static Border defaultBorder = const Border(left: BorderSide(width: 0.8, color: Colors.white,));
  static Border highlightBorder = const Border(left: BorderSide(width: 0.8, style: BorderStyle.none,));
  static BoxShadow highlightBoxShadow = const BoxShadow(color: Color.fromARGB(255, 110, 147, 226), offset: Offset(1, 0));
  static BoxDecoration defaultDecoration = const BoxDecoration();
  static BoxDecoration cursorDecoration = const BoxDecoration(
    border: Border(
      left: BorderSide(width: 1, color: Colors.yellow,)
    )
  );

  static bool cursorDragging = false;

  Widget? boxText;
  List<BoxShadow> boxShadows = [];
  TextStyle? currStyle;
  Decoration? currDecoration;
  Border? currBorder;
  Color? currHighlightColor;

  @override
  void initState() {
    currStyle = defaultStyle;
    currBorder = defaultBorder;
    boxText = RichText(
      text: TextSpan(
        text: widget.text,
        style: currStyle,
      )
    );
    if (widget.isEnvVariable) {
      boxText = ... // text now becomes key of env variable, query EnvVarManager for its value
    }
    super.initState();
  }

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      child: MouseRegion(
        cursor: WidgetStateMouseCursor.textable,
        child: Container(
          decoration: BoxDecoration(
            border: currBorder,
            boxShadow: boxShadows,
            color: currHighlightColor,
          ),
          child: boxText,
        ),
      ),
      onPanDown() {

      }
    );
  }
}

Handling Dynamic Updates

The implementation is a bit messy, but the idea was to parameterize everything that contributes to the four key features. Parameters in BoxDecoration could change when the RuneWidget is selected or when the cursor is on it. The boxText could have different text styles based on the text colorizer.

To dynamically update these parameters based on user input, I used an event-driven approach. All widgets subscribe to an EventManager and listen for specific events. For example:

  • When a click event occurs.

  • When a selection event is triggered.

  • When an environment variable’s value changes.

When these events occur, the RuneWidget updates itself accordingly.

Moving Forward

With these setups in place, I began tackling the features one by one. The goal was to build a functional and intuitive code editor that could handle environment variables seamlessly, all while providing a smooth user experience.

Implementing the Three Key Features

With the basic structure of the RuneWidget in place, I focused on implementing the three core features: the blinking cursor, text selection, and dynamic updates for environment variable values.

1. Cursor and Its Blinking

The blinking cursor was relatively straightforward to implement. I set up a periodic job in the RuneWidget to alternate the appearance of the left border, creating the blinking effect. The _showCursor method is triggered when the GestureDetector detects a click (onPanDown callback). This method emits a CursorClickEvent to notify other RuneWidgets that they should hide their cursors. The clicked RuneWidget subscribes to CursorClickEvent and uses _hideCursor as the callback, ensuring that only one cursor is visible at a time.

Here’s the implementation:

Timer? cursorTimer;
StreamSubscription? cursorEventSubscription;

void _blinkCursor() {
  cursorTimer ??= Timer.periodic(const Duration(milliseconds: 500), (timer) {
    setState(() {
      currBorder = (currBorder == defaultBorder) ? cursorBorder : defaultBorder;
    });
  });
}

void _hideCursor(CursorClickEvent e) {
  cursorEventSubscription?.cancel();
  cursorTimer?.cancel();
  cursorTimer = null;
  if (currBorder != highlightBorder) {
    setState(() {
      currBorder = defaultBorder;
    });
  }
}

void _showCursor() {
  cursorEventSubscription?.cancel();
  widget.eventManager.emit<CursorClickEvent>(CursorClickEvent());
  cursorEventSubscription = widget.eventManager.listen<CursorClickEvent>(_hideCursor);
  if (currBorder != highlightBorder) {
    setState(() {
      currBorder = cursorBorder;
    });
    _blinkCursor();
  }
}

@override
Widget build(BuildContext context) {
  return GestureDetector(
    ...
    onPanDown: (DragDownDetails details) {
      widget.eventManager.emit<SelectionCancelEvent>(SelectionCancelEvent());
      setState(() {
        ...
      });
      _showCursor();
    },
    onPanEnd: (DragEndDetails details) {
      setState(() {
        ...
      });
    },
    ...
  );
}

2. Text Selection

Handling text selection was a bit more complex. Instead of calculating cursor positions based on mouse coordinates (as done in the tutorial), I opted for a simpler approach: each RuneWidget detects user input individually. Here’s how it works:

  1. Any RuneWidget can determine which character is being clicked.

  2. If the mouse click isn’t released, the user is selecting text.

  3. As the mouse moves, any RuneWidget it passes through is highlighted as part of the selection.

While this approach avoids complex math, it has limitations. For example, if the mouse moves vertically, entire lines should be selected, but this isn’t handled well. Additionally, MouseRegion may miss enter and exit events if the mouse moves too quickly.

3. Environment Variable Value Change

This feature was the easiest to implement, thanks to the event-based design. Whenever a profile is clicked, it broadcasts an event containing the profile’s identity. Each RuneWidget representing an environment variable listens to this event during initialization and updates its displayed value accordingly.


Addressing the Drawbacks

As a careful reader, you might have noticed some issues with this implementation. If you’ve tried the example, you’ll see that text selection doesn’t work as expected. For instance:

  • When selecting text vertically, the mouse doesn’t necessarily pass through all the text that should be selected.

  • MouseRegion may fail to detect enter and exit events if the mouse moves too quickly.

To mitigate these issues, I introduced a CursorDraggingEvent that reports the mouse’s position. Each RuneWidget listens to this event and determines whether it should be highlighted based on the reported coordinates.

Here’s the implementation:

class CursorDraggingEvent {
  int posX;
  int posY;
  CursorDraggingEvent({required this.posX, required this.posY});
}

StreamSubscription? highlightSelectionSubscription;
cursorDraggingSubscription = widget.eventManager.listen<CursorDraggingEvent>(_selectionHandler);

void _selectionHandler(CursorDraggingEvent e) {
  // Check if this widget is inside the selection
  int smallerY = min(selectionStartY, e.posY);
  int largerY = max(selectionStartY, e.posY);
  int smallerX = selectionStartX;
  int largerX = e.posX;

  if (smallerY == e.posY) {
    smallerX = e.posX;
    largerX = selectionStartX;
  }
  if (smallerY == largerY) {
    smallerX = min(selectionStartX, e.posX);
    largerX = max(selectionStartX, e.posX);
  }

  // Single-line selection
  if (smallerY == largerY && widget.posY == smallerY) {
    if (smallerX <= widget.posX && widget.posX <= largerX) {
      _highlightSelection(widget.eventManager);
    }
  }
  // Multi-line selection
  else if (widget.posY > smallerY && widget.posY < largerY) {
    _highlightSelection(widget.eventManager);
  } 
  else if (widget.posY == smallerY && widget.posX >= smallerX) {
    _highlightSelection(widget.eventManager);
  }
  else if (widget.posY == largerY && widget.posX <= largerX) {
    _highlightSelection(widget.eventManager);
  }
  else if (currBorder == highlightBorder) {
    _cancelSelection(widget.eventManager);
  }
}

void _highlightSelection() {
  if (widget.isPlaceHolder) {
    return;
  }
  highlightSelectionSubscription ??= widget.eventManager.listen<SelectionCancelEvent>(_cancelSelection);
  setState(() {
    boxShadows.add(highlightBoxShadow);
    currHighlightColor = highlightColor;
    currBorder = highlightBorder;
  });
}

void _cancelSelection(event) {
  setState(() {
    boxShadows.remove(highlightBoxShadow);
    currHighlightColor = null;
    currBorder = defaultBorder;
  });
  highlightSelectionSubscription?.cancel();
  highlightSelectionSubscription = null;
}

@override
Widget build(BuildContext context) {
  return GestureDetector(
    child: MouseRegion(
      onEnter: (PointerEnterEvent e) {
        if (cursorDragging) {
          _showCursor();
          widget.eventManager.emit<CursorDraggingEvent>(
            CursorDraggingEvent(posX: widget.posX, posY: widget.posY)
          );
        }
      },
      onExit: (PointerExitEvent e) {
        if (cursorDragging) {
          _highlightSelection();
        }
      }
    ),
    onPanDown: (DragDownDetails details) {
      widget.eventManager.emit<SelectionCancelEvent>(
        SelectionCancelEvent(posX: widget.posX, posY: widget.posY)
      );
      ...
    },
    onTapUp: (TapUpDetails details) {
      ...
      widget.eventManager.emit<SelectionCancelEvent>(
        SelectionCancelEvent(posX: widget.posX, posY: widget.posY)
      );
    }
  );
}

And this is what it looks like after everything is done:

I made this demo repo public in case anyone is interested.

Lessons Learned

While this approach improved text selection, it still had significant drawbacks:

  1. Performance Degradation: Input performance worsened as the number of text entries increased.

  2. Cursor Placement: When the mouse isn’t clicking on any character, the cursor should appear at the end of the line or file. This required additional logic to implement.

  3. Selection Issues: Some RuneWidgets failed to revert to an unselected state when CancelSelection was triggered.

  4. Reverse Selection: Selecting text in reverse (moving the mouse toward already selected content) didn’t work as expected.

This implementation taught me that skipping the math for cursor and selection positions wasn’t the right approach. A better solution would involve calculating the cursor’s position based on user input and rendering it accordingly.

The good news is that the environment-changing feature worked as expected. However, I realized that RuneWidget might not be the right level to handle user input. A more optimized approach would involve fewer components handling input, which I’ll explore in the next iteration.

Conclusion

In the end, I created a demo that, while functional, wasn’t particularly impressive. Building a code editor turned out to be far more challenging than I initially imagined. The tutorial I followed was right about many things: if the code looks complicated, there’s usually a good reason for it.

In the next article, I’ll explain a better approach (though still not the best one) and dive into how the code editor works in its improved form.


Reflections

As I wrote this article, I documented what I remembered from my journey. I eventually abandoned the RuneWidget idea when I realized it might become too complicated to scale. However, I’m not entirely convinced that this is a fundamentally bad approach. It’s possible that my own limitations—my skill level and imagination—prevented me from fully realizing its potential. For me, though, it wasn’t the best path forward, as I couldn’t envision how to extend the idea further.

From a technical perspective, I should probably dive deeper into Flutter to understand why certain strange behaviors occurred. However, from an app developer’s perspective, my priority should be to get things done quickly and choose a reliable approach that delivers results. As an ordinary developer, I don’t have a strict discipline guiding my decisions. In the end, coding something new is an art. It involves making countless implementation decisions—some good, some bad—as long as they work the way you want. I can only choose one path, share my experience, and let you decide what the best approach might be for your own projects.

P.S. I’m genuinely happy that everything ended up working, and I’m extremely satisfied that this tool has made my daily work easier by reducing the number of clicks and minimizing the mental effort required for routine tasks. If you’re interested, don’t hesitate to download the app and give it a try!

0
Subscribe to my newsletter

Read articles from Jack “Jack Chen” PiggyInaBag directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Jack “Jack Chen” PiggyInaBag
Jack “Jack Chen” PiggyInaBag