How to create a menu system on a 2.4 inch screen?
To build a menu system on a 2.4 inch 240x320 ips display, you need to combine a microcontroller, a display driver, and a software structure that handles user input, rendering, and state management. The display itself is typically driven by an SPI interface and a controller like the ILI9341 or ST7789, which are common for 240x320 resolution panels. Most 2.4 inch screens use 16-bit color depth (65,536 colors), so each pixel requires 2 bytes, meaning a full frame buffer takes 240 * 320 * 2 = 153,600 bytes, or 150 KB of RAM. That’s a critical constraint if you’re using a microcontroller like an Arduino Uno (2 KB SRAM) or an ESP32 (520 KB SRAM). For a menu system, you’ll almost always need external RAM or a display buffer in flash, or you can use a library that writes directly to the display via SPI without a full frame buffer, like Adafruit_GFX or TFT_eSPI. The hardware setup typically involves wiring the display’s CS (chip select), DC (data/command), MOSI, MISO, SCK, and backlight pins to your MCU. For example, on an ESP32, you might assign CS to GPIO 5, DC to GPIO 17, MOSI to 23, SCK to 18, and backlight to GPIO 4. The SPI clock speed can go up to 40 MHz, but 20 MHz is stable for most setups. Data transfer for a full screen refresh at 20 MHz takes roughly 153,600 bytes * 8 bits / 20,000,000 Hz = 61.44 milliseconds, but with overhead, you’re looking at 80-100 ms per frame. That’s fast enough for a menu that updates only on user input, not for animations. You’ll need a library like TFT_eSPI (optimized for ESP32) or U8g2 (for smaller MCUs) to handle the low-level drawing. The menu system itself should be event-driven: you read input from a rotary encoder, a joystick, or buttons (like four tactile switches), map that to menu actions (up, down, select, back), and update the display only when the state changes. A common approach is to use a finite state machine where each menu item is a node with a label, an action function, and a list of child nodes. For example, a top-level menu might have items like “Settings,” “Data Log,” “Calibrate,” and “About.” Each item renders as a string at a fixed Y position, with a highlight rectangle for the selected item. The font size matters: at 240 pixels wide, a 16-pixel-tall font gives you 15 lines (320 / 16 = 20, but you lose some for borders), so you can show 4-5 menu items with a scrollbar if you have more. The scroll position is an integer index, and you calculate the visible range based on the display height minus a header. For a header, you might reserve 30 pixels at the top for a title bar, leaving 290 pixels for the menu list. With a 16-pixel font, that’s 18 lines, but you’d only show 5-6 items with spacing. The highlight is drawn by filling a rectangle with a color like 0x001F (blue) and then writing the text in white. You can use a buffer for the entire screen or a partial buffer for the menu area. If you use a full buffer on an ESP32, you allocate 150 KB in PSRAM (if available) or in normal RAM if you have enough. The ESP32-S3 has 512 KB SRAM, so 150 KB is fine, but leave room for other variables. For an Arduino Uno, you can’t buffer the whole screen, so you use a library that draws primitives directly, like U8g2, which uses a 128-byte page buffer and refreshes in chunks. That’s slower but works. The menu logic should debounce inputs with a 50 ms delay to avoid flickering. For a rotary encoder, you read the A and B pins and use a state table to detect clockwise or counterclockwise turns. For buttons, use internal pull-ups and check for a low signal with a 10 ms debounce. The menu rendering function should clear only the changed area, not the whole screen, to reduce SPI traffic. For example, if the user moves from item 2 to item 3, you redraw the highlight rectangle for the old and new items, which is 240 * 16 * 2 = 7,680 bytes per rectangle, or about 15 KB total for both. At 20 MHz, that’s 6 ms, which feels instant. You can also use double buffering: draw to a 150 KB buffer in RAM, then send the entire buffer to the display via SPI. That’s 61 ms per refresh, but it avoids tearing. For a menu with submenus, you store the state as a stack of menu node pointers. When the user selects “Settings,” you push the settings menu onto the stack and render it. When they press back, you pop the stack and render the previous menu. Each menu node is a struct with a label string, a count of items, an array of child nodes, and a function pointer for actions. For example, a “Calibrate” item might call a function that starts an ADC reading loop and updates a progress bar. The progress bar is drawn as a filled rectangle that grows from left to right, using a color gradient from red to green. The width of the bar is calculated as (current_value / max_value) * 200 pixels, with a 20-pixel margin on each side. You update the bar every 100 ms by drawing a new rectangle over the old one, which requires clearing the old bar area first. The text for values like “45%” is drawn at a fixed position using a 12-pixel font. For a data log menu, you might display real-time sensor readings from an I2C sensor like the BME280 (temperature, humidity, pressure). The readings update every second, and you draw them as strings: “Temp: 23.5 C” at Y=60, “Hum: 45%” at Y=80, etc. The background is black (0x0000), and text is white (0xFFFF). You can use a different color for the header, like orange (0xFD20). The font for numbers should be monospaced to avoid shifting. A good monospaced font is 6x10 pixels, giving you 40 characters per line (240 / 6 = 40). For a settings menu, you might have items like “Brightness,” “Units,” and “Reset.” Brightness is controlled by PWM on the backlight pin. The backlight pin is usually connected to a transistor or a dedicated PWM pin on the MCU. On an ESP32, you use ledcSetup and ledcAttachPin to generate a 5 kHz PWM signal with a duty cycle from 0 to 255. The menu shows a slider: a horizontal bar 200 pixels wide, with a 10-pixel-tall thumb that moves left to right. The thumb position is calculated as (brightness_value / 255) * 200. You redraw the slider only when the value changes, which is every 50 ms during adjustment. For units, you might toggle between Celsius and Fahrenheit using a boolean flag. The menu item shows “Units: C” or “Units: F”, and on select, it flips the flag and redraws the text. The reset item shows a confirmation dialog: a box 200x80 pixels centered on the screen, with “Reset to defaults?” and two buttons: “Yes” and “No”. The buttons are drawn as rectangles with text inside. You handle input by checking if the user presses left/right to select a button, then select to confirm. The dialog is drawn by filling a rectangle with a dark gray color (0x8410) and drawing text in white. You clear the dialog by redrawing the underlying menu, which is faster if you have a buffer of the previous state. The memory for the menu system includes the display buffer (150 KB), the menu node structs (each struct is about 20 bytes, so for 50 items, that’s 1 KB), the input state (4 bytes for encoder position, 4 bytes for button states), and variables like scroll index (2 bytes), current menu pointer (4 bytes), and a stack of up to 10 levels (40 bytes). Total RAM usage is around 155 KB, which fits on an ESP32 with PSRAM. If you don’t have PSRAM, you can use a library that draws directly without a buffer, like TFT_eSPI’s “pushImage” method for sprites, but that’s slower. The SPI pins on a typical 2.4 inch display are labeled on the breakout board. The pinout for a common ILI9341 module is: VCC (3.3V or 5V), GND, CS (GPIO 5), RESET (GPIO 22), DC (GPIO 17), MOSI (GPIO 23), SCK (GPIO 18), LED (backlight, GPIO 4), and MISO (GPIO 19, optional). The display’s resolution is 240x320, but the ILI9341 supports up to 320x480, so you’re using the standard mode. The refresh rate for the ILI9341 is about 60 Hz in 16-bit mode, but with SPI overhead, you get 10-15 fps for full-screen updates. For a menu, you only update small areas, so the effective refresh is much higher. The color depth is 16-bit, with 5 bits for red (0-31), 6 bits for green (0-63), and 5 bits for blue (0-31). You pack colors as 0bRRRRRGGGGGGBBBBB, so red is 0xF800, green is 0x07E0, blue is 0x001F. White is 0xFFFF, black is 0x0000. For a highlight, you use a bright color like yellow (0xFFE0) or cyan (0x07FF). The font rendering is done by the library, which stores bitmaps for each character. A 16-pixel font takes about 16 * 16 * 128 / 8 = 4 KB per font, but most libraries compress it. TFT_eSPI uses a font format that stores only the pixel data for each character, so a 16-pixel font for ASCII (95 characters) takes about 2-3 KB. You can also use custom fonts from online tools. For a menu with icons, you can draw small 16x16 bitmaps for each item, like a gear for settings. Each bitmap is 16 * 16 * 2 = 512 bytes, and you store them in flash using PROGMEM. The menu renders the icon at X=10, Y=item_y, and the text at X=30. The total width for an icon plus text is 30 + (text_length * 16) = up to 240 pixels, so you need to truncate long text. A good practice is to limit labels to 12 characters (12 * 16 = 192 pixels, plus 30 = 222, leaving 18 pixels margin). The input device can be a rotary encoder with a button, which gives you up, down, and select. The encoder has two pins (A and B) and a common ground. You read the state of A and B on every loop, and compare to the previous state. A standard state table for a rotary encoder uses 4 states (00, 01, 11, 10) and transitions. For clockwise, the sequence is 00->01->11->10->00, and for counterclockwise, it’s 00->10->11->01->00. You implement this with a lookup table or a switch statement. The button is a separate pin with a pull-up resistor. You debounce it with a 50 ms timer. For a joystick, you use two analog pins for X and Y, and a digital pin for the button. The analog values range from 0 to 4095 on an ESP32 (12-bit ADC). You map the X value to left/right with a threshold: if X < 1000, it’s left; if X > 3000, it’s right. The Y value is for up/down. The joystick button is for select. You also need to handle idle input: if no input for 10 seconds, you can dim the backlight or go to a screensaver. The screensaver draws a simple animation like a bouncing rectangle or a clock. The clock uses the RTC of the ESP32, which you set via NTP over WiFi. The time is drawn as “HH:MM:SS” in a large font (24 pixels) at the center of the screen. The background is black, and the text is green (0x07E0). You update the time every second by redrawing the text area, which is 240 * 24 * 2 = 11,520 bytes, or about 4.6 ms at 20 MHz. The menu system’s performance depends on the SPI speed and the MCU’s clock. An ESP32 at 240 MHz can run the menu loop at 1000 Hz, but the display updates are the bottleneck. You can use DMA (Direct Memory Access) on the ESP32 to send SPI data without CPU intervention. TFT_eSPI supports DMA on SPI2, which frees the CPU to handle input while the display is updating. With DMA, you can send a full frame in 30 ms instead of 60 ms. For a menu, you don’t need full frames, but DMA helps with partial updates. The library also supports “pushSprite” for drawing pre-rendered sprites, which are stored in PSRAM. For example, you can pre-render a menu background with all items and then only update the highlight. The background is a 240x320 image in 16-bit color, which is 150 KB. You store it in flash as a compressed JPEG or a raw array. JPEG decompression takes about 50 ms on an ESP32, so you do it once at startup. The raw array is faster but takes more flash space. A 150 KB raw array is fine if you have 4 MB of flash. The menu system should be modular: you write a function for each screen (main menu, settings, data log, etc.), and they all share a common input handler. The input handler returns an action code (0 for none, 1 for up, 2 for down, 3 for select, 4 for back). The main loop calls the current screen’s update function, which checks the action and updates the display. The screen functions are stored in an array of function pointers. For example, Screen screens[] = {mainMenu, settingsMenu, dataLog, aboutScreen};. The current screen index is a variable, and you switch screens by changing the index. The back action pops the index from a stack. The stack is an array of 10 integers, with a pointer. When you enter a submenu, you push the current index and set the new index. When you press back, you pop the old index. This works for up to 10 levels. The menu system also needs to handle errors, like a display that doesn’t initialize. The initialization sequence for the ILI9341 involves sending a series of commands: reset, sleep out, pixel format set to 16-bit, memory access control, display on. The library handles this, but you can check the return value of tft.begin(). If it fails, you blink an LED or print to serial. For a production system, you also need to handle power loss: save the menu state to EEPROM or flash so the last screen is restored on boot. The EEPROM on an ESP32 is 512 bytes, so you can store the current menu index, scroll position, and settings. You write to EEPROM every time the user changes a setting, but limit writes to avoid wear (use a 10-second debounce). The display itself has a lifespan of about 30,000 hours for the backlight LED, so you can dim it to extend life. The backlight current is typically 20 mA, so at 3.3V, that’s 66 mW. With a 200 mAh battery, you can run the display for 3 hours continuously. For a menu system, you can turn off the backlight after 30 seconds of inactivity and wake on input. The wake-up is done by an interrupt on the input pin. The ESP32 supports deep sleep at 10 µA, so you can run for months on a battery if the menu is idle. The menu system’s code structure should be in a single .ino file or split into multiple files for clarity. You define constants for pins, colors, and font sizes. The main loop runs at 60 Hz, checking for input and updating the display. You use millis() for timing, not delay(), to avoid blocking. For example, a debounce timer is set to millis() + 50 when input is detected, and you ignore input until millis() > timer. The display update is also timed: you only redraw when the state changes, not every loop. This reduces power and CPU usage. The menu system can be extended with touch input if the display has a touch controller like the XPT2046. The touch controller uses SPI with a separate CS pin. The resolution is 4096x4096, but you map it to the display’s 240x320. You calibrate the touch by touching four corners and storing the min/max values. The touch input is handled in the same loop: you read the touch position and pressure, and if pressure > a threshold, you calculate which menu item was touched. The touch area for each item is a rectangle from (0, item_y) to (240, item_y + item_height). You compare the touch coordinates to these rectangles. This allows for a more intuitive menu, but it adds complexity. For a simple menu, buttons are sufficient. The menu system’s aesthetics can be improved with anti-aliased fonts, but that requires more RAM. The TFT_eSPI library supports anti-aliasing for some fonts, but it’s slower. A compromise is to use a 12-pixel font for body text and a 16-pixel font for headers. The header is centered using the library’s setTextDatum(MC_DATUM) function. For example, to center “Main Menu” at Y=10, you use tft.drawString(“Main Menu”, 120, 10, 4). The number 4 is the font size (16 pixels). The background of the header is filled with a color like 0x001F (blue), and the text is white. The menu items are drawn with