Skip to content

Instantly share code, notes, and snippets.

@oxUnd
Created December 1, 2025 10:03
Show Gist options
  • Select an option

  • Save oxUnd/0df8a6195a2702346005dd3a5623e6ca to your computer and use it in GitHub Desktop.

Select an option

Save oxUnd/0df8a6195a2702346005dd3a5623e6ca to your computer and use it in GitHub Desktop.
#include <FL/Fl.H>
#include <FL/Fl_Window.H>
#include <FL/Fl_Input.H>
#include <FL/fl_draw.H>
#include <vector>
#include <string>
#include <algorithm>
#include <cmath>
#include <fstream>
#include <sstream>
#include <cstring>
#include <sys/stat.h>
#include <unistd.h>
#include <cstdlib>
#ifdef _WIN32
#include <windows.h>
#include <shlobj.h>
#else
#include <pwd.h>
#endif
struct TodoItem {
std::string text;
bool completed;
int y_position;
int swipe_offset; // Horizontal offset for swipe gesture (positive = right, negative = left)
TodoItem(const std::string& t)
: text(t), completed(false), y_position(0), swipe_offset(0) {}
};
class ClearApp : public Fl_Window {
private:
std::vector<TodoItem> items;
int selected_index;
int drag_start_y;
int drag_start_x;
bool is_dragging;
bool is_swiping;
bool is_pulling_down; // Pull down to add new item
int pull_down_offset; // Offset for pull-down animation
int drag_offset;
int item_height;
std::string data_file;
int editing_index; // Index of item being edited
std::string editing_text; // Text being edited
int pending_click_index; // Index of item pending click (to handle double-click)
bool can_reorder; // Whether reordering is allowed (after long press)
Fl_Input* input_widget; // Input widget for editing items
int scroll_offset; // Vertical scroll offset (positive = scrolled down)
// Error message display
struct ErrorDisplay {
std::string message;
bool is_visible;
ErrorDisplay() : message(""), is_visible(false) {}
} error_display;
// Calculate luminance of a color (0-255)
double get_luminance(Fl_Color color) {
unsigned char r, g, b;
Fl::get_color(color, r, g, b);
// Use relative luminance formula (ITU-R BT.709)
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
// Get appropriate text color based on background color
Fl_Color get_text_color(Fl_Color bg_color) {
double luminance = get_luminance(bg_color);
// If background is bright (luminance > 128), use black text
// Otherwise use white text
return (luminance > 128) ? FL_BLACK : FL_WHITE;
}
// Get color based on position in list (red -> orange -> yellow gradient)
Fl_Color get_color_by_position(int position, int total_items) {
if (total_items <= 1) {
return FL_RED;
}
// Calculate ratio from 0.0 (top, red) to 1.0 (bottom, yellow)
double ratio = (double)position / (total_items - 1);
// Interpolate from red (255,0,0) -> orange (255,165,0) -> yellow (255,255,0)
unsigned char r, g, b;
if (ratio < 0.5) {
// Red to Orange (0.0 to 0.5)
double local_ratio = ratio * 2.0; // 0.0 to 1.0
r = 255;
g = (unsigned char)(165 * local_ratio);
b = 0;
} else {
// Orange to Yellow (0.5 to 1.0)
double local_ratio = (ratio - 0.5) * 2.0; // 0.0 to 1.0
r = 255;
g = (unsigned char)(165 + (255 - 165) * local_ratio);
b = 0;
}
return fl_rgb_color(r, g, b);
}
// Get application data directory path
static std::string get_data_directory() {
std::string home_dir;
std::string data_dir;
#ifdef _WIN32
// Windows: Use %APPDATA%\Clear
char appdata_path[MAX_PATH];
if (SUCCEEDED(SHGetFolderPathA(NULL, CSIDL_APPDATA, NULL, SHGFP_TYPE_CURRENT, appdata_path))) {
home_dir = appdata_path;
data_dir = home_dir + "\\Clear";
} else {
// Fallback to current directory
data_dir = ".";
}
#else
// Unix-like systems (macOS, Linux)
const char* home = getenv("HOME");
if (!home) {
// Fallback to getpwuid
struct passwd* pw = getpwuid(getuid());
if (pw) {
home = pw->pw_dir;
}
}
if (home) {
home_dir = home;
#ifdef __APPLE__
// macOS: ~/Library/Application Support/Clear
data_dir = home_dir + "/Library/Application Support/Clear";
#else
// Linux: ~/.config/Clear (or ~/.local/share/Clear)
// Using XDG_CONFIG_HOME if set, otherwise ~/.config
const char* xdg_config = getenv("XDG_CONFIG_HOME");
if (xdg_config) {
data_dir = std::string(xdg_config) + "/Clear";
} else {
data_dir = home_dir + "/.config/Clear";
}
#endif
} else {
// Fallback to current directory
data_dir = ".";
}
#endif
// Create directory if it doesn't exist (recursively)
if (data_dir != ".") {
#ifdef _WIN32
// Create all parent directories recursively on Windows
std::string path = data_dir;
size_t pos = 0;
while ((pos = path.find_first_of("\\/", pos + 1)) != std::string::npos) {
std::string dir = path.substr(0, pos);
CreateDirectoryA(dir.c_str(), NULL);
}
// Create the final directory
CreateDirectoryA(data_dir.c_str(), NULL);
#else
// Create all parent directories recursively
std::string path = data_dir;
size_t pos = 0;
while ((pos = path.find_first_of('/', pos + 1)) != std::string::npos) {
std::string dir = path.substr(0, pos);
mkdir(dir.c_str(), 0755);
}
// Create the final directory
mkdir(data_dir.c_str(), 0755);
#endif
}
return data_dir;
}
// Escape/unescape text for file storage
std::string escape_text(const std::string& text) {
std::string result;
for (char c : text) {
if (c == '\n') {
result += "\\n";
} else if (c == '\\') {
result += "\\\\";
} else {
result += c;
}
}
return result;
}
std::string unescape_text(const std::string& text) {
std::string result;
for (size_t i = 0; i < text.length(); i++) {
if (text[i] == '\\' && i + 1 < text.length()) {
if (text[i + 1] == 'n') {
result += '\n';
i++;
} else if (text[i + 1] == '\\') {
result += '\\';
i++;
} else {
result += text[i];
}
} else {
result += text[i];
}
}
return result;
}
void show_error(const std::string& message) {
// Cancel any existing timeout to prevent multiple timers
Fl::remove_timeout(hide_error_cb, this);
error_display.message = message;
error_display.is_visible = true;
// Auto-hide after 3 seconds
Fl::add_timeout(3.0, hide_error_cb, this);
redraw();
}
void hide_error() {
error_display.is_visible = false;
error_display.message = "";
Fl::remove_timeout(hide_error_cb, this);
redraw();
}
static void hide_error_cb(void* data) {
ClearApp* app = static_cast<ClearApp*>(data);
app->hide_error();
}
// Calculate text dimensions more accurately
void measure_text(const std::string& text, int& width, int& height, int font_size = 14) {
fl_font(FL_HELVETICA_BOLD, font_size);
width = (int)fl_width(text.c_str());
height = (int)fl_height();
}
// Draw rounded rectangle with specified color and corner radius
void draw_rounded_rect(int x, int y, int w, int h, int radius) {
// Draw four rounded corners using pie slices
// Top-left corner
fl_pie(x, y, radius * 2, radius * 2, 90, 180);
// Top-right corner
fl_pie(x + w - radius * 2, y, radius * 2, radius * 2, 0, 90);
// Bottom-right corner
fl_pie(x + w - radius * 2, y + h - radius * 2, radius * 2, radius * 2, 270, 360);
// Bottom-left corner
fl_pie(x, y + h - radius * 2, radius * 2, radius * 2, 180, 270);
// Draw rectangular parts (top, middle, bottom)
fl_rectf(x + radius, y, w - radius * 2, h); // Middle vertical strip
fl_rectf(x, y + radius, radius, h - radius * 2); // Left strip
fl_rectf(x + w - radius, y + radius, radius, h - radius * 2); // Right strip
}
// Draw rounded rectangle border
void draw_rounded_rect_border(int x, int y, int w, int h, int radius) {
// Draw four corner arcs
fl_arc(x, y, radius * 2, radius * 2, 90, 180); // Top-left
fl_arc(x + w - radius * 2, y, radius * 2, radius * 2, 0, 90); // Top-right
fl_arc(x + w - radius * 2, y + h - radius * 2, radius * 2, radius * 2, 270, 360); // Bottom-right
fl_arc(x, y + h - radius * 2, radius * 2, radius * 2, 180, 270); // Bottom-left
// Draw straight edges
fl_line(x + radius, y, x + w - radius, y); // Top
fl_line(x + w, y + radius, x + w, y + h - radius); // Right
fl_line(x + w - radius, y + h, x + radius, y + h); // Bottom
fl_line(x, y + h - radius, x, y + radius); // Left
}
void save_to_file() {
std::ofstream file(data_file);
if (!file.is_open()) {
show_error("Failed to save file: " + data_file);
return;
}
for (const auto& item : items) {
// Save with color index 0 for backward compatibility (color is now position-based)
file << "0|"
<< (item.completed ? "1" : "0") << "|"
<< escape_text(item.text) << "\n";
}
file.close();
// Check if write failed
if (file.fail()) {
show_error("Error saving file: " + data_file);
}
}
bool load_from_file() {
std::ifstream file(data_file);
if (!file.is_open()) {
// File doesn't exist or can't be opened
// This is normal for first run, so don't show error
return false;
}
items.clear();
std::string line;
bool loaded_any = false;
while (std::getline(file, line)) {
if (line.empty()) continue;
size_t pos1 = line.find('|');
if (pos1 == std::string::npos) continue;
size_t pos2 = line.find('|', pos1 + 1);
if (pos2 == std::string::npos) continue;
// Color index is stored but not used (for backward compatibility)
bool completed = (line.substr(pos1 + 1, pos2 - pos1 - 1) == "1");
std::string text = unescape_text(line.substr(pos2 + 1));
TodoItem item(text);
item.completed = completed;
items.push_back(item);
loaded_any = true;
}
file.close();
// Check if read failed
if (file.fail() && !file.eof()) {
show_error("Error reading file: " + data_file);
}
return loaded_any; // Return true if we loaded at least one item
}
void add_sample_items() {
// Add sample items for first-time users
items.push_back(TodoItem("Welcome to Clear"));
items.push_back(TodoItem("Pull down to add new task"));
items.push_back(TodoItem("Click to edit task"));
items.push_back(TodoItem("Double-click to complete"));
items.push_back(TodoItem("Swipe right to delete"));
items.push_back(TodoItem("Long press to reorder"));
}
void draw_item(int index, int y, bool is_editing = false) {
if (index < 0 || index >= (int)items.size()) return;
TodoItem& item = items[index];
item.y_position = y;
int x_offset = item.swipe_offset;
int abs_offset = abs(x_offset);
// Get color based on position in list (not item's stored color)
Fl_Color item_color = get_color_by_position(index, items.size());
// Draw swipe background based on direction
if (x_offset > 0) {
// Swiped right (finger moves right) - item moves right, show complete background (green) on left
int right_offset = abs_offset;
if (right_offset > w()) right_offset = w();
fl_color(FL_GREEN);
fl_rectf(0, y, right_offset, item_height);
fl_color(FL_WHITE);
fl_font(FL_HELVETICA_BOLD, 16);
fl_draw("COMPLETE", right_offset / 2 - 40, y + item_height / 2 + 5);
} else if (x_offset < 0) {
// Swiped left (finger moves left) - item moves left, show delete background (red) on right
int left_offset = abs_offset;
if (left_offset > w()) left_offset = w();
fl_color(FL_RED);
fl_rectf(w() - left_offset, y, left_offset, item_height);
fl_color(FL_WHITE);
fl_font(FL_HELVETICA_BOLD, 16);
fl_draw("DELETE", w() - left_offset / 2 - 30, y + item_height / 2 + 5);
}
// Draw colored background (shifted by swipe)
int bg_x = (x_offset > 0) ? abs_offset : 0;
int bg_w = w() - abs_offset;
if (bg_w < 0) bg_w = 0;
if (is_editing) {
// When editing, only draw background in the left 20px padding area
// Fl_Input widget will handle the rest
fl_color(item_color);
fl_rectf(bg_x, y, 20, item_height);
} else {
// When not editing, draw full background and text
fl_color(item_color);
fl_rectf(bg_x, y, bg_w, item_height);
// Draw text with appropriate color based on background
Fl_Color text_color = get_text_color(item_color);
fl_color(text_color);
fl_font(FL_HELVETICA_BOLD, 18);
std::string display_text = item.text;
if (item.completed) {
display_text = "✓ " + display_text;
}
int text_x = (x_offset > 0) ? (abs_offset + 20) : (bg_x + 20);
fl_draw(display_text.c_str(), text_x, y + item_height / 2 + 6);
}
}
int get_item_at_y(int y) {
int start_y = 0; // Start from top
// Adjust y coordinate for scroll offset
int adjusted_y = y + scroll_offset;
if (adjusted_y < start_y) return -1; // Above first item
int index = (adjusted_y - start_y) / item_height;
if (index >= 0 && index < (int)items.size()) {
return index;
}
return -1;
}
// Get maximum scroll offset (how far we can scroll down)
int get_max_scroll_offset() {
int total_height = items.size() * item_height;
int visible_height = h() - 40; // Subtract space for instructions at bottom
int max_scroll = total_height - visible_height;
return (max_scroll > 0) ? max_scroll : 0;
}
// Clamp scroll offset to valid range
void clamp_scroll_offset() {
int max_scroll = get_max_scroll_offset();
if (scroll_offset < 0) {
scroll_offset = 0;
} else if (scroll_offset > max_scroll) {
scroll_offset = max_scroll;
}
}
void reorder_items(int from_index, int to_index) {
if (from_index < 0 || from_index >= (int)items.size() ||
to_index < 0 || to_index >= (int)items.size() ||
from_index == to_index) {
return;
}
TodoItem item = items[from_index];
items.erase(items.begin() + from_index);
items.insert(items.begin() + to_index, item);
save_to_file();
redraw();
}
public:
ClearApp(int W, int H, const char* title)
: Fl_Window(W, H, title), selected_index(-1),
is_dragging(false), is_swiping(false), is_pulling_down(false),
pull_down_offset(0), drag_offset(0), item_height(60),
data_file(""), editing_index(-1), pending_click_index(-1),
can_reorder(false), input_widget(nullptr), scroll_offset(0) {
// Initialize data file path to application data directory
std::string data_dir = get_data_directory();
if (data_dir == ".") {
data_file = "todos.txt"; // Fallback to current directory
} else {
#ifdef _WIN32
data_file = data_dir + "\\todos.txt";
#else
data_file = data_dir + "/todos.txt";
#endif
}
color(FL_WHITE);
// Create input widget (initially hidden)
input_widget = new Fl_Input(0, 0, w(), item_height);
input_widget->callback(input_callback, this);
input_widget->when(FL_WHEN_CHANGED | FL_WHEN_ENTER_KEY | FL_WHEN_RELEASE);
input_widget->box(FL_FLAT_BOX); // Flat box (background but no border)
input_widget->align(FL_ALIGN_LEFT | FL_ALIGN_INSIDE);
// Ensure the widget can receive focus and display properly
input_widget->set_visible_focus();
input_widget->hide();
// Load items from file
bool loaded = load_from_file();
// If no items loaded (first run), add sample items
if (!loaded || items.empty()) {
add_sample_items();
save_to_file(); // Save sample items to file
}
end();
}
~ClearApp() {
save_to_file();
}
void add_item(const std::string& text = "") {
// Finish any existing editing first
if (editing_index >= 0) {
finish_editing();
}
// Reset pull down state to avoid position calculation issues
is_pulling_down = false;
pull_down_offset = 0;
// Reset scroll to top when adding new item at the beginning
scroll_offset = 0;
// Insert new item at the beginning
items.insert(items.begin(), TodoItem(text));
// Start editing the new item
editing_index = 0;
editing_text = text;
start_editing(0);
save_to_file();
}
void start_editing(int index) {
if (index < 0 || index >= (int)items.size()) {
return;
}
// Finish any existing editing first
if (editing_index >= 0 && editing_index != index) {
finish_editing();
}
editing_index = index;
editing_text = items[index].text;
// Calculate position for input widget
int start_y = 0;
int item_y = start_y + index * item_height - scroll_offset;
if (is_pulling_down && pull_down_offset > 0) {
item_y += pull_down_offset;
}
// Adjust for swipe offset
int x_offset = items[index].swipe_offset;
int abs_offset = abs(x_offset);
int bg_x = (x_offset > 0) ? abs_offset : 0;
// Add 20 pixels left padding to match non-editing text position
int input_x = bg_x + 20;
int input_w = w() - abs_offset - 20;
if (input_w < 0) input_w = 0;
// Position and show input widget
// Make it cover the full item area, with left padding for text alignment
input_widget->resize(input_x, item_y, input_w, item_height);
// Set colors based on item position
Fl_Color item_color = get_color_by_position(index, items.size());
Fl_Color text_color = get_text_color(item_color);
// Set background color
input_widget->color(item_color);
// Set text color - ensure high contrast and visibility
input_widget->textcolor(text_color);
input_widget->textfont(FL_HELVETICA_BOLD);
input_widget->textsize(18);
// Set selection color for cursor visibility (use contrasting color)
Fl_Color selection_color = (text_color == FL_BLACK) ? FL_BLUE : FL_YELLOW;
input_widget->selection_color(selection_color);
// Set value - ensure it's set before showing
std::string text_value = items[index].text;
input_widget->value(text_value.c_str());
// Show and activate
input_widget->show();
input_widget->activate();
input_widget->set_visible_focus();
// Set cursor to end of text
int text_len = input_widget->size();
if (text_len > 0) {
input_widget->insert_position(text_len);
input_widget->mark(text_len); // Clear any selection
} else {
input_widget->insert_position(0);
input_widget->mark(0);
}
// Take focus to ensure input works
input_widget->take_focus();
// Force complete redraw
input_widget->damage(FL_DAMAGE_ALL);
input_widget->redraw();
// Process events to ensure everything is updated
Fl::check();
Fl::flush();
redraw();
}
void finish_editing() {
if (editing_index >= 0 && editing_index < (int)items.size()) {
// Get text from input widget
if (input_widget && input_widget->visible()) {
editing_text = input_widget->value() ? input_widget->value() : "";
}
input_widget->hide();
int old_editing_index = editing_index;
std::string old_editing_text = editing_text;
if (old_editing_text.empty()) {
// Remove empty item, but keep at least one empty item if list becomes empty
items.erase(items.begin() + old_editing_index);
if (items.empty()) {
items.push_back(TodoItem(""));
editing_index = -1; // Reset first to avoid recursion
editing_text = "";
redraw();
start_editing(0);
return;
} else {
editing_index = -1;
editing_text = "";
}
} else {
items[old_editing_index].text = old_editing_text;
editing_index = -1;
editing_text = "";
save_to_file();
}
redraw();
}
}
void delete_item(int index) {
if (index >= 0 && index < (int)items.size()) {
items.erase(items.begin() + index);
if (selected_index >= (int)items.size()) {
selected_index = -1;
}
// Reset swipe offsets for all items
for (auto& item : items) {
item.swipe_offset = 0;
}
// Clamp scroll offset after deletion
clamp_scroll_offset();
// If all items are deleted, add an empty item for input
if (items.empty()) {
items.push_back(TodoItem(""));
editing_index = 0;
editing_text = "";
scroll_offset = 0;
start_editing(0);
}
save_to_file();
redraw();
}
}
void toggle_complete(int index) {
if (index >= 0 && index < (int)items.size()) {
items[index].completed = !items[index].completed;
save_to_file();
redraw();
}
}
int handle(int event) override {
int mx = Fl::event_x();
int my = Fl::event_y();
int start_y = 0;
switch (event) {
case FL_PUSH: {
// Finish editing if clicking elsewhere
if (editing_index >= 0 && Fl::event_button() == FL_LEFT_MOUSE) {
int clicked_index = get_item_at_y(my);
if (clicked_index != editing_index) {
finish_editing();
}
}
// Reset all swipe offsets when starting new interaction
for (auto& item : items) {
item.swipe_offset = 0;
}
int index = get_item_at_y(my);
// Check if we're in the pull-down zone (above all items, not on first item)
// Pull zone is only above the first item, not including the first item itself
bool in_pull_zone = (my < start_y && index < 0);
if (index >= 0) {
// Clicked on an item (including first item)
if (Fl::event_button() == FL_LEFT_MOUSE) {
// Start potential drag - but need long press for reordering
selected_index = index;
is_dragging = false; // Don't allow dragging yet
is_swiping = false;
is_pulling_down = false;
can_reorder = false; // Need long press first
drag_start_y = my;
drag_start_x = mx;
drag_offset = my - (start_y + index * item_height);
pending_click_index = -1; // Reset pending click
// Start long press timer (0.3 seconds) for reordering
Fl::add_timeout(0.3, long_press_timeout_cb, this);
redraw();
} else if (Fl::event_button() == FL_RIGHT_MOUSE) {
// Right-click to delete
if (editing_index >= 0) {
finish_editing();
}
delete_item(index);
}
} else if (in_pull_zone && Fl::event_button() == FL_LEFT_MOUSE) {
// Click in pull-down zone (above all items) - start pull down
is_dragging = true;
is_pulling_down = true;
drag_start_y = my;
drag_start_x = mx;
pull_down_offset = 0;
selected_index = -1;
redraw();
}
return 1;
}
case FL_DRAG: {
int dx = mx - drag_start_x;
int dy = my - drag_start_y;
if (is_pulling_down) {
// Pull down gesture - immediate response, no long press needed
if (dy > 0) {
pull_down_offset = dy;
if (pull_down_offset > item_height * 1.5) {
pull_down_offset = item_height * 1.5;
}
redraw();
} else if (dy < -5) {
// Pulled back up significantly, cancel
pull_down_offset = 0;
redraw();
}
} else if (selected_index >= 0) {
// Check if dragging down to add new item (immediate, no long press needed)
// Only allow this if dragging down significantly and not swiping horizontally
if (!can_reorder && dy > 20 && abs(dx) < 30) {
// Dragging down - switch to pull-down mode to add new item
Fl::remove_timeout(long_press_timeout_cb, this);
is_pulling_down = true;
is_dragging = true;
selected_index = -1;
pull_down_offset = dy;
redraw();
} else if (!can_reorder) {
// Check if this is a horizontal swipe (for complete or delete)
if (abs(dx) > 10 && abs(dx) > abs(dy)) {
// Horizontal swipe - allow it immediately
is_swiping = true;
is_dragging = true;
items[selected_index].swipe_offset = dx; // Can be positive (right) or negative (left)
redraw();
} else if (abs(dx) > 5 || abs(dy) > 5) {
// Moved but not dragging down or swiping - cancel long press timer
Fl::remove_timeout(long_press_timeout_cb, this);
can_reorder = false;
// Don't allow dragging yet
return 1;
} else {
// Small movement, wait for long press
return 1;
}
}
// Only allow reordering after long press
if (!can_reorder && !is_swiping) {
return 1;
}
// Now we can drag for reordering or swiping
if (!is_swiping) {
is_dragging = true;
}
// Determine if this is a horizontal swipe or vertical drag
if (!is_swiping && abs(dx) > 10) {
is_swiping = true;
}
if (is_swiping) {
// Horizontal swipe - can be right (complete) or left (delete)
items[selected_index].swipe_offset = dx;
redraw();
} else if (can_reorder && abs(dy) > 10) {
// Vertical drag for reordering (only after long press)
int new_index = get_item_at_y(my);
if (new_index >= 0 && new_index != selected_index) {
reorder_items(selected_index, new_index);
selected_index = new_index;
}
redraw();
}
}
return 1;
}
case FL_RELEASE: {
// Cancel long press timer if still waiting
Fl::remove_timeout(long_press_timeout_cb, this);
if (is_pulling_down) {
// If pulled down enough, create new item
if (pull_down_offset > item_height * 0.6) {
// Add new item (this will reset pull down state and start editing)
add_item("");
} else {
pull_down_offset = 0;
is_pulling_down = false;
}
} else if (is_dragging && selected_index >= 0 && can_reorder) {
if (is_swiping) {
// Handle swipe based on direction
int swipe_offset = items[selected_index].swipe_offset;
if (swipe_offset < -w() * 0.3) {
// Swiped left far enough - delete
if (editing_index == selected_index) {
editing_index = -1;
editing_text = "";
}
delete_item(selected_index);
} else if (swipe_offset > w() * 0.3) {
// Swiped right far enough - complete
toggle_complete(selected_index);
items[selected_index].swipe_offset = 0;
} else {
// Snap back
items[selected_index].swipe_offset = 0;
}
}
} else if (selected_index >= 0) {
// Released without long press or without dragging
int dx = mx - drag_start_x;
int dy = my - drag_start_y;
if (is_swiping) {
// Handle swipe based on direction
int swipe_offset = items[selected_index].swipe_offset;
if (swipe_offset < -w() * 0.3) {
// Swiped left far enough - delete
if (editing_index == selected_index) {
editing_index = -1;
editing_text = "";
}
delete_item(selected_index);
} else if (swipe_offset > w() * 0.3) {
// Swiped right far enough - complete
toggle_complete(selected_index);
items[selected_index].swipe_offset = 0;
} else {
// Snap back
items[selected_index].swipe_offset = 0;
}
} else if (abs(dx) < 5 && abs(dy) < 5 && !can_reorder) {
// Small movement - treat as click
// Check if this is a double-click
if (Fl::event_clicks() > 0) {
// Double-click - toggle complete
// Cancel any pending single click
if (pending_click_index >= 0) {
Fl::remove_timeout(click_timeout_cb, this);
pending_click_index = -1;
}
if (editing_index < 0) {
toggle_complete(selected_index);
}
} else {
// Single click - delay to check for double-click
// Cancel previous pending click if any
if (pending_click_index >= 0) {
Fl::remove_timeout(click_timeout_cb, this);
}
pending_click_index = selected_index;
Fl::add_timeout(0.3, click_timeout_cb, this);
}
}
}
is_dragging = false;
is_swiping = false;
can_reorder = false;
redraw();
return 1;
}
case FL_MOUSEWHEEL: {
// Handle mouse wheel scrolling
int dy = Fl::event_dy();
if (dy != 0) {
scroll_offset -= dy * item_height; // Negative because scrolling down should increase offset
clamp_scroll_offset();
redraw();
return 1;
}
break;
}
case FL_KEYBOARD: {
// If editing, only handle Escape key, let Fl_Input handle everything else
if (editing_index >= 0 && input_widget && input_widget->visible()) {
int key = Fl::event_key();
if (key == FL_Escape) {
// Cancel editing
std::string current_text = input_widget->value() ? input_widget->value() : "";
if (current_text.empty() && editing_index < (int)items.size()) {
items.erase(items.begin() + editing_index);
if (items.empty()) {
items.push_back(TodoItem(""));
editing_index = 0;
editing_text = "";
start_editing(0);
return 1;
}
}
input_widget->hide();
editing_index = -1;
editing_text = "";
save_to_file();
redraw();
return 1;
}
// For all other keys, don't intercept - let Fl_Input handle them
// Call the parent handle to let event propagate naturally
break; // Don't handle, let it fall through to parent or Fl_Input
} else if (Fl::event_key() == FL_Delete && selected_index >= 0) {
delete_item(selected_index);
selected_index = -1;
return 1;
}
break;
}
}
return Fl_Window::handle(event);
}
void draw() override {
Fl_Window::draw();
int start_y = 0;
int y = start_y;
// Draw pull-down new item if pulling
if (is_pulling_down && pull_down_offset > 0) {
int new_item_y = start_y - item_height + pull_down_offset - scroll_offset;
// Only draw if visible
if (new_item_y + item_height > 0 && new_item_y < h()) {
// Draw the new item being pulled down
// New item will be at position 0, so use red color
Fl_Color new_color = get_color_by_position(0, items.size() + 1);
fl_color(new_color);
fl_rectf(0, new_item_y, w(), item_height);
// Use appropriate text color based on background
Fl_Color text_color = get_text_color(new_color);
fl_color(text_color);
fl_font(FL_HELVETICA_BOLD, 18);
if (pull_down_offset > item_height * 0.6) {
fl_draw("Release to add...", 20, new_item_y + item_height / 2 + 6);
} else {
fl_draw("Pull down to add...", 20, new_item_y + item_height / 2 + 6);
}
}
}
// Draw all items (shift down when pulling, adjust for scroll)
for (size_t i = 0; i < items.size(); i++) {
int item_y = y - scroll_offset;
if (is_pulling_down && pull_down_offset > 0) {
// Shift all items down when pulling
item_y += pull_down_offset;
}
// Only draw if item is visible (optimization)
if (item_y + item_height > 0 && item_y < h()) {
draw_item(i, item_y, (i == (size_t)editing_index));
}
y += item_height;
}
// Update Fl_Input position if editing
if (editing_index >= 0 && editing_index < (int)items.size() && input_widget && input_widget->visible()) {
int start_y = 0;
int item_y = start_y + editing_index * item_height - scroll_offset;
if (is_pulling_down && pull_down_offset > 0) {
item_y += pull_down_offset;
}
// Ensure editing item is visible by scrolling if needed
if (item_y < 0) {
scroll_offset += item_y;
clamp_scroll_offset();
item_y = start_y + editing_index * item_height - scroll_offset;
if (is_pulling_down && pull_down_offset > 0) {
item_y += pull_down_offset;
}
} else if (item_y + item_height > h() - 40) {
scroll_offset += (item_y + item_height) - (h() - 40);
clamp_scroll_offset();
item_y = start_y + editing_index * item_height - scroll_offset;
if (is_pulling_down && pull_down_offset > 0) {
item_y += pull_down_offset;
}
}
// Adjust for swipe offset
int x_offset = items[editing_index].swipe_offset;
int abs_offset = abs(x_offset);
int bg_x = (x_offset > 0) ? abs_offset : 0;
// Add 20 pixels left padding to match non-editing text position
int input_x = bg_x + 20;
int input_w = w() - abs_offset - 20;
if (input_w < 0) input_w = 0;
// Update position and colors
input_widget->resize(input_x, item_y, input_w, item_height);
Fl_Color item_color = get_color_by_position(editing_index, items.size());
Fl_Color text_color = get_text_color(item_color);
input_widget->color(item_color);
input_widget->textcolor(text_color);
// Set selection color for cursor visibility
Fl_Color selection_color = (text_color == FL_BLACK) ? FL_BLUE : FL_YELLOW;
input_widget->selection_color(selection_color);
// Force redraw
input_widget->redraw();
}
// Draw instructions at bottom
fl_color(FL_GRAY);
fl_font(FL_HELVETICA, 12);
fl_draw("Pull down to add | Click to edit | Double-click to complete | Swipe right to delete",
10, h() - 20);
// Draw error message in bottom right corner
if (error_display.is_visible && !error_display.message.empty()) {
const int font_size = 14;
const int padding = 12;
const int margin = 10;
const int corner_radius = 8;
fl_font(FL_HELVETICA_BOLD, font_size);
// Measure text accurately
int text_w, text_h;
measure_text(error_display.message, text_w, text_h, font_size);
// Calculate box dimensions with padding
int box_w = text_w + padding * 2;
int box_h = text_h + padding * 2;
// Ensure minimum width for better appearance
if (box_w < 200) box_w = 200;
// Position in bottom right corner (above instructions)
int box_x = w() - box_w - margin;
int box_y = h() - box_h - 35; // Above the instruction text
// Save current drawing state
fl_push_clip(box_x, box_y, box_w, box_h);
// Draw shadow for depth (offset by 2 pixels) with rounded corners
fl_color(fl_rgb_color(20, 20, 20)); // Dark gray for shadow effect
draw_rounded_rect(box_x + 2, box_y + 2, box_w, box_h, corner_radius);
// Draw rounded rectangle background with 90% opacity (10% transparency)
// For 90% opacity on white background (255,255,255):
// Target color = base_color * 0.9 + white * 0.1
// If we want final color around (60,60,60):
// base_color = (60 - 255*0.1) / 0.9 ≈ 38
// Final = 38*0.9 + 255*0.1 = 34.2 + 25.5 ≈ 60
// Using base color (40,40,40) for better visibility:
// Final = 40*0.9 + 255*0.1 = 36 + 25.5 ≈ 62
fl_color(fl_rgb_color(62, 62, 62));
draw_rounded_rect(box_x, box_y, box_w, box_h, corner_radius);
// Restore clipping
fl_pop_clip();
// Draw error text in white
fl_color(FL_WHITE);
fl_draw(error_display.message.c_str(),
box_x + padding,
box_y + padding + text_h - 4);
}
}
void handle_single_click(int index) {
if (index >= 0 && index < (int)items.size() && pending_click_index == index) {
// This is a single click (not a double-click), start editing
if (editing_index != index) {
start_editing(index);
}
pending_click_index = -1;
}
}
void enable_reorder() {
if (selected_index >= 0) {
can_reorder = true;
is_dragging = true;
redraw();
}
}
static void input_callback(Fl_Widget* widget, void* data) {
ClearApp* app = static_cast<ClearApp*>(data);
Fl_Input* input = app->input_widget;
if (!input || !input->visible()) return;
// Check if Enter was pressed
int key = Fl::event_key();
if (key == FL_Enter || key == FL_KP_Enter) {
app->finish_editing();
return;
}
// Text changed - force immediate redraw to show input in real-time
input->damage(FL_DAMAGE_ALL);
input->redraw();
// Process events immediately to update display
Fl::check();
Fl::flush();
}
static void click_timeout_cb(void* data) {
ClearApp* app = static_cast<ClearApp*>(data);
if (app->pending_click_index >= 0) {
app->handle_single_click(app->pending_click_index);
}
}
static void long_press_timeout_cb(void* data) {
ClearApp* app = static_cast<ClearApp*>(data);
app->enable_reorder();
}
};
int main(int argc, char** argv) {
ClearApp* app = new ClearApp(600, 800, "Clear - Todo List");
app->show(argc, argv);
return Fl::run();
}
#!/bin/bash
# Create icon for Clear app using ImageMagick
# Generate .icns file for macOS
set -e
ICONSET_DIR="Clear.iconset"
rm -rf "$ICONSET_DIR"
mkdir -p "$ICONSET_DIR"
# Create base 1024x1024 icon following Apple HIG guidelines
# Background must fill entire canvas (1024x1024) - no transparency
# Main content (checkmark) should be in safe area (10-20% margin from edges)
# According to Apple: https://developer.apple.com/design/human-interface-guidelines/app-icons
TEMP_ICON="$ICONSET_DIR/icon_1024.png"
magick -size 1024x1024 \
gradient:"rgb(255,70,70)-rgb(255,220,60)" \
-alpha off \
-fill white \
-stroke white \
-strokewidth 200 \
-draw "path 'M 250,450 L 512,750 L 800,250'" \
-alpha off \
"$TEMP_ICON"
# Generate all required sizes for iconset
echo "Generating icon sizes..."
# 16x16
magick "$TEMP_ICON" -resize 16x16 "$ICONSET_DIR/icon_16x16.png"
# 16x16@2x (32x32)
magick "$TEMP_ICON" -resize 32x32 "$ICONSET_DIR/[email protected]"
# 32x32
magick "$TEMP_ICON" -resize 32x32 "$ICONSET_DIR/icon_32x32.png"
# 32x32@2x (64x64)
magick "$TEMP_ICON" -resize 64x64 "$ICONSET_DIR/[email protected]"
# 128x128
magick "$TEMP_ICON" -resize 128x128 "$ICONSET_DIR/icon_128x128.png"
# 128x128@2x (256x256)
magick "$TEMP_ICON" -resize 256x256 "$ICONSET_DIR/[email protected]"
# 256x256
magick "$TEMP_ICON" -resize 256x256 "$ICONSET_DIR/icon_256x256.png"
# 256x256@2x (512x512)
magick "$TEMP_ICON" -resize 512x512 "$ICONSET_DIR/[email protected]"
# 512x512
magick "$TEMP_ICON" -resize 512x512 "$ICONSET_DIR/icon_512x512.png"
# 512x512@2x (1024x1024)
cp "$TEMP_ICON" "$ICONSET_DIR/[email protected]"
# Remove temporary 1024x1024 file
rm -f "$TEMP_ICON"
# Convert iconset to .icns
echo "Converting to .icns format..."
iconutil -c icns "$ICONSET_DIR" -o "Clear.icns"
# Clean up temporary iconset directory
rm -rf "$ICONSET_DIR"
echo "Icon file created: Clear.icns"
echo "✓ Icon generation complete"
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string>clear</string>
<key>CFBundleIdentifier</key>
<string>com.clear.todolist</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>Clear</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>LSMinimumSystemVersion</key>
<string>10.13</string>
<key>NSHighResolutionCapable</key>
<true/>
<key>NSPrincipalClass</key>
<string>NSApplication</string>
<key>CFBundleIconFile</key>
<string>Clear.icns</string>
<key>CFBundleIconFiles</key>
<array>
<string>Clear.icns</string>
</array>
<key>LSApplicationCategoryType</key>
<string>public.app-category.productivity</string>
</dict>
</plist>
CXX = g++
CXXFLAGS = -Wall -std=c++11
LDFLAGS = -lfltk
# Detect OS and adjust flags
UNAME_S := $(shell uname -s)
ifeq ($(UNAME_S),Darwin)
# macOS
FLTK_PREFIX = /opt/homebrew
FLTK_INCLUDE = $(FLTK_PREFIX)/include
FLTK_LIB = $(FLTK_PREFIX)/lib
LDFLAGS = -L$(FLTK_LIB) -lfltk -framework Cocoa
CXXFLAGS += -I$(FLTK_INCLUDE)
else
FLTK_PREFIX = /usr/local
FLTK_INCLUDE = $(FLTK_PREFIX)/include
FLTK_LIB = $(FLTK_PREFIX)/lib
LDFLAGS = -L$(FLTK_LIB) -lfltk
CXXFLAGS += -I$(FLTK_INCLUDE)
endif
TARGET = clear
TARGET_STATIC = clear-static
SOURCE = clear.cc
all: $(TARGET)
static: $(TARGET_STATIC)
$(TARGET): $(SOURCE)
$(CXX) $(CXXFLAGS) -o $(TARGET) $(SOURCE) $(LDFLAGS)
# Static build - links FLTK statically
$(TARGET_STATIC): $(SOURCE)
ifeq ($(UNAME_S),Darwin)
# macOS: link FLTK static libraries directly
@echo "Building static version for macOS..."
@Z_STATIC=$$(find $(FLTK_PREFIX)/Cellar/zlib -name "libz.a" 2>/dev/null | head -1); \
if [ -z "$$Z_STATIC" ]; then \
Z_STATIC="-lz"; \
fi; \
echo "Using zlib: $$Z_STATIC"; \
$(CXX) $(CXXFLAGS) -o $(TARGET_STATIC) $(SOURCE) \
$(FLTK_LIB)/libfltk.a \
$(FLTK_LIB)/libfltk_forms.a \
$(FLTK_LIB)/libfltk_gl.a \
$(FLTK_LIB)/libfltk_images.a \
$(FLTK_LIB)/libpng16.a \
$(FLTK_LIB)/libjpeg.a \
$$Z_STATIC \
-lm \
-framework Cocoa -framework ApplicationServices -framework IOKit \
-framework ScreenCaptureKit
else
# Linux: use static linking flags
@echo "Building static version for Linux..."
$(CXX) $(CXXFLAGS) -static -o $(TARGET_STATIC) $(SOURCE) \
-L$(FLTK_LIB) -lfltk -lX11 -lXext -lpthread -ldl -lm
endif
icon: Clear.icns
Clear.icns:
@if [ ! -f create_icon.sh ]; then \
echo "Error: create_icon.sh not found"; \
exit 1; \
fi
@./create_icon.sh
app: $(TARGET_STATIC) Clear.icns
@echo "Creating macOS .app bundle..."
@rm -rf Clear.app
@mkdir -p Clear.app/Contents/MacOS
@mkdir -p Clear.app/Contents/Resources
@cp $(TARGET_STATIC) Clear.app/Contents/MacOS/clear
@cp Info.plist Clear.app/Contents/Info.plist
@cp Clear.icns Clear.app/Contents/Resources/
@chmod +x Clear.app/Contents/MacOS/clear
@echo "Clear.app bundle created successfully!"
clean:
rm -f $(TARGET) $(TARGET_STATIC)
rm -rf Clear.app
rm -rf Clear.iconset
rm -f Clear.icns
.PHONY: all static app icon clean
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment