diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..420a1f6 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +customtkinter==5.2.2 +darkdetect==0.8.0 +packaging==26.0 diff --git a/typing_speed_test.py b/typing_speed_test.py index 6c0eb48..2ac9fed 100644 --- a/typing_speed_test.py +++ b/typing_speed_test.py @@ -1,19 +1,25 @@ import customtkinter as ctk import random import time +import threading +import urllib.request +import json # --- Theme Configuration --- ctk.set_appearance_mode("light") ctk.set_default_color_theme("blue") -SENTENCES = [ - "Technology is the most effective way to change the world", - "Innovation is the ability to see change as an opportunity -not a threat", - "Artificial Intelligence is not a threat to creativity; it's a catalyst for innovation.", +# Fallback sentences used when the API is unreachable +FALLBACK_SENTENCES = [ + "Technology is the most effective way to change the world.", + "Innovation is the ability to see change as an opportunity, not a threat.", + "Artificial Intelligence is not a threat to creativity; it is a catalyst for innovation.", "Data is the canvas, and AI is the brush that paints the picture of insights.", "Artificial Intelligence: where innovation meets computation in the pursuit of a smarter tomorrow." ] +QUOTE_API_URL = "http://api.quotable.io/random" + class TypingSpeedTest(ctk.CTk): def __init__(self): super().__init__() @@ -26,6 +32,8 @@ def __init__(self): self.current_sentence = "" self.time_left = 60 self.timer_running = False + self.scores = [] + self.mode = "60s" # "60s" or "15s" # --- UI LAYOUT --- self.title_label = ctk.CTkLabel(self, text="Typing Speed Test", font=("Helvetica", 28, "bold")) @@ -55,9 +63,37 @@ def __init__(self): self.feedback_label = ctk.CTkLabel(self, text="", font=("Helvetica", 12, "bold")) self.feedback_label.pack() + # Mode Selector + self.mode_frame = ctk.CTkFrame(self, fg_color="transparent") + self.mode_frame.pack(pady=(10, 0)) + + self.btn_60s = ctk.CTkButton( + self.mode_frame, + text="⏱ 60s Mode", + font=("Helvetica", 13, "bold"), + command=lambda: self.set_mode("60s"), + height=34, + width=130, + fg_color="#3B8ED0", + hover_color="#2775b3" + ) + self.btn_60s.grid(row=0, column=0, padx=6) + + self.btn_15s = ctk.CTkButton( + self.mode_frame, + text="⚡ 15s Mode", + font=("Helvetica", 13, "bold"), + command=lambda: self.set_mode("15s"), + height=34, + width=130, + fg_color="gray", + hover_color="#555555" + ) + self.btn_15s.grid(row=0, column=1, padx=6) + # Button Container self.button_frame = ctk.CTkFrame(self, fg_color="transparent") - self.button_frame.pack(pady=20) + self.button_frame.pack(pady=10) self.start_button = ctk.CTkButton( self.button_frame, @@ -79,39 +115,93 @@ def __init__(self): ) self.result_button.grid(row=0, column=1, padx=10) + self.scoreboard_button = ctk.CTkButton( + self.button_frame, + text="🏆 Scoreboard", + font=("Helvetica", 14, "bold"), + command=self.show_scoreboard, + height=40, + fg_color="#5A5A8A", + hover_color="#3D3D6B" + ) + self.scoreboard_button.grid(row=0, column=2, padx=10) + self.result_label = ctk.CTkLabel(self, text="", font=("Helvetica", 18, "italic")) self.result_label.pack(pady=10) - # Available sentences - self.available_sentences = SENTENCES.copy() - random.shuffle(self.available_sentences) + # Fallback pool (shuffled), used only when API is unavailable + self.fallback_pool = FALLBACK_SENTENCES.copy() + random.shuffle(self.fallback_pool) + + def set_mode(self, mode): + """Switch between '60s' and '15s' modes (only when not mid-test).""" + if self.timer_running: + return # Ignore mode switch during an active test + self.mode = mode + duration = 60 if mode == "60s" else 15 + self.timer_label.configure(text=f"Time Remaining: {duration}s", text_color="#3B8ED0") + # Highlight active mode button + self.btn_60s.configure(fg_color="#3B8ED0" if mode == "60s" else "gray") + self.btn_15s.configure(fg_color="#3B8ED0" if mode == "15s" else "gray") + + def fetch_quote(self): + """Fetch a random quote from the API. Returns the quote string or None on failure.""" + try: + with urllib.request.urlopen(QUOTE_API_URL, timeout=5) as response: + data = json.loads(response.read().decode()) + return data.get("content", "").strip() + except Exception: + return None def start_test(self): # --- Reset everything for a new test --- self.result_label.configure(text="") self.feedback_label.configure(text="") - # Clear input field properly + # Clear input field and disable it while loading self.input_textbox.configure(state="normal") self.input_textbox.delete("1.0", "end") - self.input_textbox.focus() - self.input_textbox.update() + self.input_textbox.configure(state="disabled") + + # Show loading state + self.sentence_label.configure(text="Loading quote...", text_color="gray") + self.start_button.configure(state="disabled") + self.result_button.configure(state="disabled", fg_color="gray") - # Pick new sentence - if not self.available_sentences: - self.available_sentences = SENTENCES.copy() - random.shuffle(self.available_sentences) - self.current_sentence = self.available_sentences.pop() + # Fetch the quote in a background thread to keep UI responsive + threading.Thread(target=self._load_quote_and_begin, daemon=True).start() + + def _load_quote_and_begin(self): + """Background thread: fetch quote, then schedule UI update on main thread.""" + quote = self.fetch_quote() + if not quote: + # Fallback to built-in sentences + if not self.fallback_pool: + self.fallback_pool = FALLBACK_SENTENCES.copy() + random.shuffle(self.fallback_pool) + quote = self.fallback_pool.pop() + # Schedule the rest of the setup back on the main thread + self.after(0, lambda q=quote: self._begin_test(q)) + + def _begin_test(self, sentence): + """Called on the main thread once the quote is ready.""" + self.current_sentence = sentence self.sentence_label.configure(text=self.current_sentence, text_color="white") - # Reset timer - self.time_left = 60 + # Re-enable input and focus + self.input_textbox.configure(state="normal") + self.input_textbox.delete("1.0", "end") + self.input_textbox.focus() + self.input_textbox.update() + + # Reset timer based on selected mode + duration = 60 if self.mode == "60s" else 15 + self.time_left = duration self.timer_running = True - self.timer_label.configure(text="Time Remaining: 60s", text_color="#3B8ED0") + self.timer_label.configure(text=f"Time Remaining: {duration}s", text_color="#3B8ED0") self.start_time = time.time() # Update buttons - self.start_button.configure(state="disabled") self.result_button.configure(state="normal", fg_color="#3B8ED0") # Start timer @@ -121,7 +211,8 @@ def update_timer(self): if self.time_left > 0 and self.timer_running: self.time_left -= 1 self.timer_label.configure(text=f"Time Remaining: {self.time_left}s") - if self.time_left <= 10: + red_zone = 5 if self.mode == "15s" else 10 + if self.time_left <= red_zone: self.timer_label.configure(text_color="#FF4C4C") self.after(1000, self.update_timer) elif self.time_left == 0: @@ -173,6 +264,35 @@ def check_result(self, event=None): self.result_button.configure(state="disabled", fg_color="gray") self.start_time = None + # Update in-memory scoreboard (top 5) + self.scores.append(round(wpm, 2)) + self.scores.sort(reverse=True) + self.scores = self.scores[:5] + + def show_scoreboard(self): + win = ctk.CTkToplevel(self) + win.title("Scoreboard") + win.geometry("300x320") + win.resizable(False, False) + win.grab_set() + + ctk.CTkLabel(win, text="🏆 Top 5 Scores", font=("Helvetica", 20, "bold")).pack(pady=16) + + medals = ["🥇", "🥈", "🥉", "4️⃣", "5️⃣"] + + if not self.scores: + ctk.CTkLabel(win, text="No scores yet.\nComplete a test to see results!", + font=("Helvetica", 14), text_color="gray", justify="center").pack(pady=20) + else: + for i, score in enumerate(self.scores): + ctk.CTkLabel( + win, + text=f"{medals[i]} {score:.2f} WPM", + font=("Helvetica", 16) + ).pack(pady=6) + + ctk.CTkButton(win, text="Close", command=win.destroy, height=36).pack(pady=16) + if __name__ == "__main__": app = TypingSpeedTest() app.mainloop() \ No newline at end of file