Annexe : Application - Éditeur de Code

Rôle et utilité

apps/editeur/app.py est une application capitale de l'écosystème GrimOS. Plus avancé que le simple Bloc-notes, c'est l'outil de développement officiel du système. Puisque GrimOS encourage l'utilisateur à modifier son propre code source, il était impératif de fournir un outil capable de lire du Python confortablement.

Implémentation technique

Pistes de modification

Code Source

import tkinter as tk
from tkinter import filedialog, messagebox
import re
import os

class LineNumbers(tk.Canvas):
    def __init__(self, *args, **kwargs):
        tk.Canvas.__init__(self, *args, **kwargs)
        self.textwidget = None

    def attach(self, text_widget):
        self.textwidget = text_widget

    def redraw(self, *args):
        self.delete("all")
        i = self.textwidget.index("@0,0")
        while True :
            dline= self.textwidget.dlineinfo(i)
            if dline is None: break
            y = dline[1]
            linenum = str(i).split(".")[0]
            self.create_text(2, y, anchor="nw", text=linenum, font=("Consolas", 10))
            i = self.textwidget.index("%s+1line" % i)

def start(window, app_manager=None, filepath=None, **kwargs):
    frame = tk.Frame(window)
    frame.pack(fill="both", expand=True)

    toolbar = tk.Frame(frame, bg="lightgray", bd=1, relief="raised")
    toolbar.pack(side="top", fill="x")

    current_file = [filepath] if filepath else [None]

    text_frame = tk.Frame(frame)
    text_frame.pack(fill="both", expand=True)

    linenumbers = LineNumbers(text_frame, width=35, bg="lightgrey")
    linenumbers.pack(side="left", fill="y")

    scrollbar = tk.Scrollbar(text_frame)
    scrollbar.pack(side="right", fill="y")

    text_area = tk.Text(text_frame, font=("Consolas", 11), yscrollcommand=scrollbar.set, undo=True)
    text_area.pack(side="left", fill="both", expand=True)
    scrollbar.config(command=text_area.yview)

    linenumbers.attach(text_area)

    text_area.tag_configure("keyword", foreground="blue", font=("Consolas", 11, "bold"))
    text_area.tag_configure("string", foreground="green")
    text_area.tag_configure("comment", foreground="gray", font=("Consolas", 11, "italic"))

    KEYWORDS = ["def", "class", "import", "from", "return", "if", "elif", "else", "for", "while", "in", "and", "or", "not", "True", "False", "None", "try", "except", "pass", "break", "continue", "with", "as"]

    def highlight_syntax(event=None):
        text_area.tag_remove("keyword", "1.0", tk.END)
        text_area.tag_remove("string", "1.0", tk.END)
        text_area.tag_remove("comment", "1.0", tk.END)

        content = text_area.get("1.0", tk.END)

        # Strings
        for match in re.finditer(r'".*?"|\'.*?\'', content):
            start_pos = text_area.index(f"1.0 + {match.start()} chars")
            end_pos = text_area.index(f"1.0 + {match.end()} chars")
            text_area.tag_add("string", start_pos, end_pos)

        # Comments
        for match in re.finditer(r'#.*', content):
            start_pos = text_area.index(f"1.0 + {match.start()} chars")
            end_pos = text_area.index(f"1.0 + {match.end()} chars")
            text_area.tag_add("comment", start_pos, end_pos)

        # Keywords
        for kw in KEYWORDS:
            for match in re.finditer(rf'\b{kw}\b', content):
                start_pos = text_area.index(f"1.0 + {match.start()} chars")
                end_pos = text_area.index(f"1.0 + {match.end()} chars")
                tags = text_area.tag_names(start_pos)
                if "string" not in tags and "comment" not in tags:
                    text_area.tag_add("keyword", start_pos, end_pos)

    def on_text_change(event=None):
        if text_area.edit_modified() or event is not None:
            linenumbers.redraw()
            if hasattr(window, 'highlight_after'):
                window.after_cancel(window.highlight_after)
            window.highlight_after = window.after(500, highlight_syntax)
            text_area.edit_modified(False)

    text_area.bind("<<Modified>>", lambda event: on_text_change())
    text_area.bind("<KeyRelease>", on_text_change)
    text_area.bind("<MouseWheel>", lambda e: window.after(10, linenumbers.redraw))
    text_area.bind("<Button-1>", lambda e: window.after(10, linenumbers.redraw))

    def auto_indent(event):
        line = text_area.get("insert linestart", "insert")
        match = re.match(r'^([ \t]+)', line)
        if match:
            text_area.insert("insert", "\n" + match.group(1))
            return "break"

    text_area.bind("<Return>", auto_indent)

    def update_title(title_text):
        if hasattr(window, 'master') and window.master.__class__.__name__ == 'Window':
            window.master.title_label.config(text=title_text)

    if current_file[0] and os.path.exists(current_file[0]):
        try:
            with open(current_file[0], 'r', encoding='utf-8') as f:
                text_area.insert("1.0", f.read())
            update_title(f"Éditeur de Code - {os.path.basename(current_file[0])}")
            highlight_syntax()
            linenumbers.redraw()
        except Exception as e:
            messagebox.showerror("Erreur", str(e))
    else:
        update_title("Éditeur de Code - Nouveau Fichier")

    def open_file():
        path = filedialog.askopenfilename()
        if path:
            current_file[0] = path
            try:
                with open(path, 'r', encoding='utf-8') as f:
                    text_area.delete("1.0", tk.END)
                    text_area.insert("1.0", f.read())
                update_title(f"Éditeur de Code - {os.path.basename(path)}")
                highlight_syntax()
                linenumbers.redraw()
            except Exception as e:
                messagebox.showerror("Erreur", str(e))

    def save_file():
        if not current_file[0]:
            save_as_file()
        else:
            try:
                with open(current_file[0], 'w', encoding='utf-8') as f:
                    f.write(text_area.get("1.0", tk.END))
                messagebox.showinfo("Sauvegarde", "Fichier sauvegardé avec succès.")
            except Exception as e:
                messagebox.showerror("Erreur", str(e))

    def save_as_file():
        path = filedialog.asksaveasfilename(defaultextension=".py")
        if path:
            current_file[0] = path
            save_file()
            update_title(f"Éditeur de Code - {os.path.basename(path)}")

    def new_file():
        current_file[0] = None
        text_area.delete("1.0", tk.END)
        update_title("Éditeur de Code - Nouveau Fichier")
        highlight_syntax()
        linenumbers.redraw()

    def new_gui_file():
        new_file()
        boilerplate = '''import tkinter as tk
from tkinter import messagebox

def start(window, app_manager=None, filepath=None, **kwargs):
    # Changement du titre de la fenêtre GrimOS (si exécuté dans GrimOS)
    if hasattr(window, "master") and hasattr(window.master, "title_label"):
        window.master.title_label.config(text="Mon App GrimOS")

    # Conteneur principal
    frame = tk.Frame(window, bg="white")
    frame.pack(fill="both", expand=True, padx=10, pady=10)

    # Instructions
    tk.Label(frame, text="Saisissez un texte :", font=("Arial", 12), bg="white").pack(pady=(10, 0))

    # Zone de saisie (1 ligne)
    entry_var = tk.StringVar()
    entry = tk.Entry(frame, textvariable=entry_var, font=("Arial", 12), width=30)
    entry.pack(pady=10)

    def on_click():
        texte = entry_var.get()
        messagebox.showinfo("Popup", f"Texte saisi : {texte}")

    # Bouton d'action
    btn = tk.Button(frame, text="Afficher le Popup", command=on_click, font=("Arial", 11))
    btn.pack(pady=10)

# Point d'entrée pour tester l'application indépendamment de GrimOS
if __name__ == "__main__":
    root = tk.Tk()
    root.geometry("400x300")

    # En environnement GrimOS (bare X11 sans gestionnaire de fenêtres), tk.Tk n'a pas de bordures.
    # Nous ajoutons une fausse barre de titre pour pouvoir fermer et tester l'application sereinement.
    title_bar = tk.Frame(root, bg="darkblue", relief="raised", bd=1)
    title_bar.pack(fill="x")
    tk.Label(title_bar, text="Test Mode - Mon App", bg="darkblue", fg="white", font=("Arial", 10, "bold")).pack(side="left", padx=5)
    tk.Button(title_bar, text="X", bg="red", fg="white", command=root.destroy, bd=0, padx=5).pack(side="right")

    # Zone de contenu
    content = tk.Frame(root)
    content.pack(fill="both", expand=True)

    start(content)
    root.mainloop()
'''
        text_area.insert("1.0", boilerplate)
        highlight_syntax()
        linenumbers.redraw()

    def show_help():
        aide = "Éditeur de Code GrimOS\n\n"
        aide += "COMPATIBILITÉ GRIMOS (Règles d'Or) :\n"
        aide += "1. Votre code DOIT avoir une fonction `start(window, app_manager=None, **kwargs)`.\n"
        aide += "2. L'argument `window` fourni est un Frame Tkinter pré-créé par le système. "
        aide += "Attachez TOUS vos éléments visuels (Boutons, Labels) à `window`.\n"
        aide += "3. Ne créez JAMAIS de `tk.Tk()` dans la fonction `start()`, cela casserait le système.\n\n"
        aide += "CONSEILS :\n"
        aide += "- Utilisez le bouton 'Nouveau GUI' pour générer une structure parfaite.\n"
        aide += "- Le bloc `if __name__ == '__main__':` sert uniquement à tester l'application en standalone."
        messagebox.showinfo("Aide & Documentation", aide)

    btn_new = tk.Button(toolbar, text=" Nouveau", image=(app_manager.desktop.icons.get("menu_new_file") if app_manager else None), compound="left", command=new_file)
    btn_new.pack(side="left", padx=2, pady=2)

    btn_new_gui = tk.Button(toolbar, text="Nouveau GUI", command=new_gui_file)
    btn_new_gui.pack(side="left", padx=2, pady=2)

    btn_open = tk.Button(toolbar, text=" Ouvrir", image=(app_manager.desktop.icons.get("menu_open") if app_manager else None), compound="left", command=open_file)
    btn_open.pack(side="left", padx=2, pady=2)

    btn_save = tk.Button(toolbar, text=" Enregistrer", image=(app_manager.desktop.icons.get("menu_save") if app_manager else None), compound="left", command=save_file)
    btn_save.pack(side="left", padx=2, pady=2)

    btn_saveas = tk.Button(toolbar, text=" Enregistrer sous...", image=(app_manager.desktop.icons.get("menu_save_as") if app_manager else None), compound="left", command=save_as_file)
    btn_saveas.pack(side="left", padx=2, pady=2)

    btn_help = tk.Button(toolbar, text=" Aide", image=(app_manager.desktop.icons.get("btn_help") if app_manager else None), compound="left", command=show_help)
    btn_help.pack(side="right", padx=2, pady=2)

    window.after(100, linenumbers.redraw)