From 847280f9b132aae03423e1aa037f2b586d41d738 Mon Sep 17 00:00:00 2001 From: hasanyildiz Date: Thu, 29 Oct 2026 11:57:30 +0000 Subject: [PATCH 1/3] Group the Language menu by first letter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keep Text at the top of the Language menu and list the other languages alphabetically in submenus of whole initials (A – H, I – M, N – R, S – Z) built by groupLanguagesByInitial, so the menu stays short as languages are added. --- README.md | 2 +- src/domain/SupportedLanguage.ts | 55 +++++++++++++++++++ .../__tests__/SupportedLanguage.test.ts | 34 +++++++++++- src/utils/ApplicationMenu.ts | 7 +-- src/utils/LanguageMenu.ts | 25 +++++++++ 5 files changed, 116 insertions(+), 7 deletions(-) create mode 100644 src/utils/LanguageMenu.ts diff --git a/README.md b/README.md index ed96fd6..0c48ac3 100644 --- a/README.md +++ b/README.md @@ -137,7 +137,7 @@ The usual editing shortcuts of Ace (multiple cursors, moving and duplicating lin ## Supported languages -Syntax highlighting is chosen from the file name or extension, and can be changed from the Language menu: +Syntax highlighting is chosen from the file name or extension, and can be changed from the Language menu, which keeps Text at the top and groups the other languages alphabetically (A – H, I – M, N – R, S – Z): Plain text, JavaScript, TypeScript, JSON, XML, HTML, CSS, YAML, Markdown, Java, Kotlin, C/C++, C#, Go, Rust, Python, Ruby, PHP, SQL, PL/SQL, Shell, Batch, PowerShell, INI, Properties, TOML, Dockerfile and Makefile. diff --git a/src/domain/SupportedLanguage.ts b/src/domain/SupportedLanguage.ts index 516c3f6..45bebcb 100644 --- a/src/domain/SupportedLanguage.ts +++ b/src/domain/SupportedLanguage.ts @@ -197,4 +197,59 @@ export function findLanguageByFileName(fileName: string | undefined): SupportedL const extension = baseName.substring(dotIndex).toLowerCase(); return languages.find(l => l.extensions.includes(extension)) || SupportedLanguages[LanguageName.TEXT]; +} + +export interface LanguageGroup { + label: string, + languages: Array +} + +const FIRST_INITIAL = "A"; +const LAST_INITIAL = "Z"; + +function getInitial(language: SupportedLanguage) { + return language.label.charAt(0).toUpperCase(); +} + +function getNextInitial(initial: string) { + return String.fromCharCode(initial.charCodeAt(0) + 1); +} + +function compareLabels(a: SupportedLanguage, b: SupportedLanguage) { + return a.label.localeCompare(b.label, "en", {sensitivity: "base"}); +} + +function groupByInitial(languages: Array) { + return languages.reduce>>((groups, language) => { + const lastGroup = groups[groups.length - 1]; + if (lastGroup && getInitial(lastGroup[0]) === getInitial(language)) lastGroup.push(language); + else groups.push([language]); + return groups; + }, []); +} + +function mergeUpTo(maxGroupSize: number, groups: Array>) { + return groups.reduce>>((merged, group) => { + const lastGroup = merged[merged.length - 1]; + if (lastGroup && lastGroup.length + group.length <= maxGroupSize) lastGroup.push(...group); + else merged.push([...group]); + return merged; + }, []); +} + +function createRangeLabel(first: string, last: string) { + return first === last ? first : `${first} \u2013 ${last}`; +} + +export function groupLanguagesByInitial(languages: Array, maxGroupSize: number): Array { + const groups = mergeUpTo(maxGroupSize, groupByInitial([...languages].sort(compareLabels))); + const lastInitials = groups.map(group => getInitial(group[group.length - 1])); + + return groups.map((group, index) => ({ + label: createRangeLabel( + index === 0 ? FIRST_INITIAL : getNextInitial(lastInitials[index - 1]), + index === groups.length - 1 ? LAST_INITIAL : lastInitials[index] + ), + languages: group + })); } \ No newline at end of file diff --git a/src/domain/__tests__/SupportedLanguage.test.ts b/src/domain/__tests__/SupportedLanguage.test.ts index f4e8246..a5b8c8c 100644 --- a/src/domain/__tests__/SupportedLanguage.test.ts +++ b/src/domain/__tests__/SupportedLanguage.test.ts @@ -1,5 +1,5 @@ import {describe, expect, it} from "vitest"; -import {findLanguageByFileName, LanguageName} from "../SupportedLanguage"; +import {findLanguageByFileName, groupLanguagesByInitial, LanguageName, SupportedLanguage, SupportedLanguages} from "../SupportedLanguage"; describe("findLanguageByFileName", () => { it("finds the language by extension", () => { @@ -32,3 +32,35 @@ describe("findLanguageByFileName", () => { expect(findLanguageByFileName("Main.class").name).toBe(LanguageName.TEXT); }); }); + +describe("groupLanguagesByInitial", () => { + const languageNamed = (label: string): SupportedLanguage => ({name: label.toLowerCase(), label: label, extensions: []}); + const labelsOf = (languages: Array) => languages.map(l => l.label); + + it("sorts languages by label and groups whole initials into ranges covering the alphabet", () => { + const groups = groupLanguagesByInitial(["Go", "batch", "CSS", "C#", "Java", "JSON", "Rust", "YAML"].map(languageNamed), 3); + + expect(groups.map(g => g.label)).toEqual(["A \u2013 C", "D \u2013 J", "K \u2013 Z"]); + expect(groups.map(g => labelsOf(g.languages))).toEqual([["batch", "C#", "CSS"], ["Go", "Java", "JSON"], ["Rust", "YAML"]]); + }); + + it("keeps an initial larger than the group size together", () => { + const groups = groupLanguagesByInitial(["Perl", "PHP", "Python", "Ruby"].map(languageNamed), 2); + + expect(groups.map(g => labelsOf(g.languages))).toEqual([["Perl", "PHP", "Python"], ["Ruby"]]); + expect(groups.map(g => g.label)).toEqual(["A \u2013 P", "Q \u2013 Z"]); + }); + + it("labels a single group as the whole alphabet and a one-letter range as that letter", () => { + expect(groupLanguagesByInitial([languageNamed("Go")], 5).map(g => g.label)).toEqual(["A \u2013 Z"]); + expect(groupLanguagesByInitial(["Ada", "Bash", "Zig"].map(languageNamed), 1).map(g => g.label)).toEqual(["A", "B", "C \u2013 Z"]); + }); + + it("puts every supported language in exactly one group", () => { + const languages = Object.values(SupportedLanguages); + const grouped = groupLanguagesByInitial(languages, 7).flatMap(g => g.languages); + + expect(grouped).toHaveLength(languages.length); + expect(new Set(grouped)).toEqual(new Set(languages)); + }); +}); diff --git a/src/utils/ApplicationMenu.ts b/src/utils/ApplicationMenu.ts index d4897ec..950f642 100644 --- a/src/utils/ApplicationMenu.ts +++ b/src/utils/ApplicationMenu.ts @@ -1,7 +1,6 @@ import {app, BrowserWindow, dialog, Menu, MenuItemConstructorOptions, nativeImage, shell} from "electron"; import path from "node:path"; import {EventType, SystemPaths, TabAction} from "../enums"; -import {SupportedLanguages} from "../domain/SupportedLanguage"; import {Settings} from "../domain/Settings"; import {getSystemPath, isFileExists} from "./FileUtils"; import {openInWindow} from "./EditorUtils"; @@ -15,6 +14,7 @@ import {ShortcutId} from "./Shortcuts"; import {getAccelerator} from "./Accelerators"; import {getUserPluginManifests, getUserPluginPath, loadUserPluginManifests} from "./UserPlugins"; import {createEncodingMenu} from "./EncodingMenu"; +import {createLanguageMenu} from "./LanguageMenu"; import {createSearchMenu} from "./SearchMenu"; import {missingRecentFileNotice} from "./DialogUtils"; import {broadcast, notify, sendPluginAction, sendToFocusedWindow} from "./WindowUtils"; @@ -74,10 +74,7 @@ function createApplicationMenu() { }, {label: "Search", submenu: createSearchMenu(command => sendToFocusedWindow(EventType.EDITOR_COMMAND, {command: command}))}, {label: "Encoding", submenu: createEncodingMenu(sendToFocusedWindow)}, - { - label: "Language", - submenu: Object.values(SupportedLanguages).map(language => ({label: language.label, click: sendAction(EventType.SET_TAB_LANGUAGE, language)})) - }, + {label: "Language", submenu: createLanguageMenu(sendToFocusedWindow)}, { label: "Preferences", submenu: [ diff --git a/src/utils/LanguageMenu.ts b/src/utils/LanguageMenu.ts new file mode 100644 index 0000000..6831d2c --- /dev/null +++ b/src/utils/LanguageMenu.ts @@ -0,0 +1,25 @@ +import {groupLanguagesByInitial, LanguageName, SupportedLanguage, SupportedLanguages} from "../domain/SupportedLanguage"; +import {EventType} from "../enums"; +import type {Sender} from "./WindowUtils"; + +type MenuItem = Electron.MenuItemConstructorOptions; + +const LANGUAGE_GROUP_SIZE = 7; + +export function createLanguageMenu(send: Sender): Array { + const plainText = SupportedLanguages[LanguageName.TEXT]; + const otherLanguages = Object.values(SupportedLanguages).filter(language => language !== plainText); + + return [ + createLanguageItem(send, plainText), + {type: "separator"}, + ...groupLanguagesByInitial(otherLanguages, LANGUAGE_GROUP_SIZE).map(group => ({ + label: group.label, + submenu: group.languages.map(language => createLanguageItem(send, language)) + })) + ]; +} + +function createLanguageItem(send: Sender, language: SupportedLanguage): MenuItem { + return {label: language.label, click: () => send(EventType.SET_TAB_LANGUAGE, language)}; +} From 00f2656520c622e99b20ebe0bbb3fe62c7a91b4a Mon Sep 17 00:00:00 2001 From: hasanyildiz Date: Thu, 29 Oct 2026 11:57:30 +0000 Subject: [PATCH 2/3] Bump version to 1.8.0 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 3cde504..7db6a2d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "note-manager", - "version": "1.7.1", + "version": "1.8.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "note-manager", - "version": "1.7.1", + "version": "1.8.0", "license": "MIT", "workspaces": [ "tools/shims/glob", diff --git a/package.json b/package.json index e84b3f7..090fbfa 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "note-manager", "productName": "Note Manager", - "version": "1.7.1", + "version": "1.8.0", "description": "A lightweight, fast and multi-functional desktop text editor", "homepage": "https://github.com/Note-Manager/note-manager#readme", "repository": { From 3249cb982e381b24eeac6bfefe74441ecea9e824 Mon Sep 17 00:00:00 2001 From: hasanyildiz Date: Thu, 29 Oct 2026 12:38:23 +0000 Subject: [PATCH 3/3] Group languages by single first letters Drop the letter ranges: languages that share a first letter go into a submenu named after that letter, and a letter with one language stays a plain item. --- README.md | 2 +- src/domain/SupportedLanguage.ts | 43 +++---------------- .../__tests__/SupportedLanguage.test.ts | 28 +++++------- src/utils/LanguageMenu.ts | 15 ++++--- 4 files changed, 24 insertions(+), 64 deletions(-) diff --git a/README.md b/README.md index 0c48ac3..3db3111 100644 --- a/README.md +++ b/README.md @@ -137,7 +137,7 @@ The usual editing shortcuts of Ace (multiple cursors, moving and duplicating lin ## Supported languages -Syntax highlighting is chosen from the file name or extension, and can be changed from the Language menu, which keeps Text at the top and groups the other languages alphabetically (A – H, I – M, N – R, S – Z): +Syntax highlighting is chosen from the file name or extension, and can be changed from the Language menu, which keeps Text at the top and lists the other languages alphabetically, with languages that share a first letter grouped under that letter: Plain text, JavaScript, TypeScript, JSON, XML, HTML, CSS, YAML, Markdown, Java, Kotlin, C/C++, C#, Go, Rust, Python, Ruby, PHP, SQL, PL/SQL, Shell, Batch, PowerShell, INI, Properties, TOML, Dockerfile and Makefile. diff --git a/src/domain/SupportedLanguage.ts b/src/domain/SupportedLanguage.ts index 45bebcb..e7c8bc9 100644 --- a/src/domain/SupportedLanguage.ts +++ b/src/domain/SupportedLanguage.ts @@ -200,56 +200,23 @@ export function findLanguageByFileName(fileName: string | undefined): SupportedL } export interface LanguageGroup { - label: string, + initial: string, languages: Array } -const FIRST_INITIAL = "A"; -const LAST_INITIAL = "Z"; - function getInitial(language: SupportedLanguage) { return language.label.charAt(0).toUpperCase(); } -function getNextInitial(initial: string) { - return String.fromCharCode(initial.charCodeAt(0) + 1); -} - function compareLabels(a: SupportedLanguage, b: SupportedLanguage) { return a.label.localeCompare(b.label, "en", {sensitivity: "base"}); } -function groupByInitial(languages: Array) { - return languages.reduce>>((groups, language) => { +export function groupLanguagesByInitial(languages: Array): Array { + return [...languages].sort(compareLabels).reduce>((groups, language) => { const lastGroup = groups[groups.length - 1]; - if (lastGroup && getInitial(lastGroup[0]) === getInitial(language)) lastGroup.push(language); - else groups.push([language]); + if (lastGroup?.initial === getInitial(language)) lastGroup.languages.push(language); + else groups.push({initial: getInitial(language), languages: [language]}); return groups; }, []); -} - -function mergeUpTo(maxGroupSize: number, groups: Array>) { - return groups.reduce>>((merged, group) => { - const lastGroup = merged[merged.length - 1]; - if (lastGroup && lastGroup.length + group.length <= maxGroupSize) lastGroup.push(...group); - else merged.push([...group]); - return merged; - }, []); -} - -function createRangeLabel(first: string, last: string) { - return first === last ? first : `${first} \u2013 ${last}`; -} - -export function groupLanguagesByInitial(languages: Array, maxGroupSize: number): Array { - const groups = mergeUpTo(maxGroupSize, groupByInitial([...languages].sort(compareLabels))); - const lastInitials = groups.map(group => getInitial(group[group.length - 1])); - - return groups.map((group, index) => ({ - label: createRangeLabel( - index === 0 ? FIRST_INITIAL : getNextInitial(lastInitials[index - 1]), - index === groups.length - 1 ? LAST_INITIAL : lastInitials[index] - ), - languages: group - })); } \ No newline at end of file diff --git a/src/domain/__tests__/SupportedLanguage.test.ts b/src/domain/__tests__/SupportedLanguage.test.ts index a5b8c8c..08a79ac 100644 --- a/src/domain/__tests__/SupportedLanguage.test.ts +++ b/src/domain/__tests__/SupportedLanguage.test.ts @@ -35,30 +35,22 @@ describe("findLanguageByFileName", () => { describe("groupLanguagesByInitial", () => { const languageNamed = (label: string): SupportedLanguage => ({name: label.toLowerCase(), label: label, extensions: []}); - const labelsOf = (languages: Array) => languages.map(l => l.label); - it("sorts languages by label and groups whole initials into ranges covering the alphabet", () => { - const groups = groupLanguagesByInitial(["Go", "batch", "CSS", "C#", "Java", "JSON", "Rust", "YAML"].map(languageNamed), 3); + it("sorts languages by label and groups them by their first letter", () => { + const groups = groupLanguagesByInitial(["Go", "CSS", "batch", "C#", "Java", "JSON", "Rust"].map(languageNamed)); - expect(groups.map(g => g.label)).toEqual(["A \u2013 C", "D \u2013 J", "K \u2013 Z"]); - expect(groups.map(g => labelsOf(g.languages))).toEqual([["batch", "C#", "CSS"], ["Go", "Java", "JSON"], ["Rust", "YAML"]]); - }); - - it("keeps an initial larger than the group size together", () => { - const groups = groupLanguagesByInitial(["Perl", "PHP", "Python", "Ruby"].map(languageNamed), 2); - - expect(groups.map(g => labelsOf(g.languages))).toEqual([["Perl", "PHP", "Python"], ["Ruby"]]); - expect(groups.map(g => g.label)).toEqual(["A \u2013 P", "Q \u2013 Z"]); - }); - - it("labels a single group as the whole alphabet and a one-letter range as that letter", () => { - expect(groupLanguagesByInitial([languageNamed("Go")], 5).map(g => g.label)).toEqual(["A \u2013 Z"]); - expect(groupLanguagesByInitial(["Ada", "Bash", "Zig"].map(languageNamed), 1).map(g => g.label)).toEqual(["A", "B", "C \u2013 Z"]); + expect(groups.map(g => [g.initial, g.languages.map(l => l.label)])).toEqual([ + ["B", ["batch"]], + ["C", ["C#", "CSS"]], + ["G", ["Go"]], + ["J", ["Java", "JSON"]], + ["R", ["Rust"]] + ]); }); it("puts every supported language in exactly one group", () => { const languages = Object.values(SupportedLanguages); - const grouped = groupLanguagesByInitial(languages, 7).flatMap(g => g.languages); + const grouped = groupLanguagesByInitial(languages).flatMap(g => g.languages); expect(grouped).toHaveLength(languages.length); expect(new Set(grouped)).toEqual(new Set(languages)); diff --git a/src/utils/LanguageMenu.ts b/src/utils/LanguageMenu.ts index 6831d2c..0dcfc3d 100644 --- a/src/utils/LanguageMenu.ts +++ b/src/utils/LanguageMenu.ts @@ -1,11 +1,9 @@ -import {groupLanguagesByInitial, LanguageName, SupportedLanguage, SupportedLanguages} from "../domain/SupportedLanguage"; +import {groupLanguagesByInitial, LanguageGroup, LanguageName, SupportedLanguage, SupportedLanguages} from "../domain/SupportedLanguage"; import {EventType} from "../enums"; import type {Sender} from "./WindowUtils"; type MenuItem = Electron.MenuItemConstructorOptions; -const LANGUAGE_GROUP_SIZE = 7; - export function createLanguageMenu(send: Sender): Array { const plainText = SupportedLanguages[LanguageName.TEXT]; const otherLanguages = Object.values(SupportedLanguages).filter(language => language !== plainText); @@ -13,13 +11,16 @@ export function createLanguageMenu(send: Sender): Array { return [ createLanguageItem(send, plainText), {type: "separator"}, - ...groupLanguagesByInitial(otherLanguages, LANGUAGE_GROUP_SIZE).map(group => ({ - label: group.label, - submenu: group.languages.map(language => createLanguageItem(send, language)) - })) + ...groupLanguagesByInitial(otherLanguages).map(group => createGroupItem(send, group)) ]; } +function createGroupItem(send: Sender, group: LanguageGroup): MenuItem { + if (group.languages.length === 1) return createLanguageItem(send, group.languages[0]); + + return {label: group.initial, submenu: group.languages.map(language => createLanguageItem(send, language))}; +} + function createLanguageItem(send: Sender, language: SupportedLanguage): MenuItem { return {label: language.label, click: () => send(EventType.SET_TAB_LANGUAGE, language)}; }