تجربه نوشتن ادیتور متنی تحت ترمینال با جاوای خالص

به عنوان پروژه درس ساختمان داده، از ما خواسته شد ساختمان داده piece table را پیاده‌سازی کنیم و آن را در یک ادیتور متنی به کار بگیریم. قوانین پروژه عدم استفاده از هرگونه import و include بود (به جز اسکنر جاوا و stdio در c/c++) و زبان‌های قابل استفاده هم محدود به همین دو زبان جاوا و سی‌پلاس‌پلاس بود.


اسکرین‌شات جهت زیبایی است و از ادیتور neovim گرفته شده و ارتباطی به ادیتور مورد بحث ندارد
اسکرین‌شات جهت زیبایی است و از ادیتور neovim گرفته شده و ارتباطی به ادیتور مورد بحث ندارد



چون هدف اصلی، طراحی ساختمان داده بود و من نمی‌خواستم مدیریت حافظه دردسر دومی برایم باشد، جاوا را انتخاب کردم. از مزیت‌های این انتخاب این بود که بدون دیباگر و ابزار کمکی می‌توانستم دسترسی به خارج از آرایه را تشخیص بدهم و در مدیریت حافظه و نداشتن نشتی حافظه هم کارم ساده‌تر بود. اما همه این‌ها مربوط به قبل از شروع پیاده‌سازی بود. در ادامه اتفاقاتی که وقعا افتاد را با هم بررسی می‌کنیم:

۱- گرفتن ورودی از کاربر

یک ادیتور تحت ترمینال باید به صورت لحظه‌ای به ورودی‌های کاربر واکنش نشان دهد، یعنی باید بتواند هر هر کلیدی که روی کیبورد زده می‌شود را دریافت کند. مثلا تابع getch در زبان سی برای اینکار مناسب است، چرا که در همان لحظه که کلیدی زده می‌شود ورودی دریافت می‌شود و کد اسکی آن به دست می‌آید.

اما در جاوا وضعیت چگونه است؟ هیچ تابعی معادل getch وجود ندارد! تنها راه ورودی گرفتن ما اسکنر بود که باید صبر می‌کردیم کاربر اینتر را وارد کند تا یک رشته ورودی بگیریم.. این غیر قابل قبول است! کاربران در اینترنت هم تنها راهی که به ذهنشان می‌رسید استفاده از سویینگ و معادل‌های آن و تعریف Action listener بود اما من فقط اجازه استفاده از جاوا را داشتم.

راه حل: وقتی از سرچ ناامید شدم، تصمیم گرفتم خود فایل System.in را بررسی کنم تا ببینم چه متد‌هایی دارد. آیا به اسکنر تک کاراکتر به تک کاراکتر خروجی می‌دهد؟ متوجه شدم که خود System.in یک کلاس منحصر به فرد نیست و یک شی از نوع InputStream است.

بررسی کلاس InputStream چه نتیجه‌ای می‌دهد؟ این کلاس متد های read و mark و close دارد که متدهای mark و close در مورد System.in نتیجه خوبی نمی‌دهند و غیر قابل استفاده هستند. اما متد read متدی بود که به کار ما آمد.

این متد (Read) یک مرتبه هم اورلود شده است، هم می‌توان بدون ورودی دادن آن را فراخوانی کرد که در این صورت خروجی آن، مقدار اولین بایت ورودی است. به نظر خوب می‌آید. هر کاراکتر در یک بایت جا می‌گیرد و مثل getch می تواند عمل کند.

اما خواندن مستقیم از یک InputStream می‌تواند منجر به پرتاب استثنای IOException شود، البته که احتمالا در شرایط واقعی برنامه ما این اتفاق نخواهد افتاد ولی به هر حال استثنای چک شده‌است و باید مدیریت شود.

مشکل بعدی این است که همه کلید‌های روی کیبورد، تک کارکتری نیستند که بتوانیم در یک بایت ورودی بگیریمشان. مهم‌ترین کلید‌هایی که لازم داشتیم arrow keys بودند. کلید‌های جا به جا شدن در متن که به صورت ۳ کاراکتر ظاهر می‌شوند.

این ۳ کاراکتر، کاراکتر‌های ۲۷ (اسکیپ) و ۹۱ و کاراکتر سوم ۶۵ تا ۶۸ بود. ۶۵ ما را یاد A می‌اندازد و همین‌طور با D. حالا برای این ۴ کاراکتر چه تدبیری باید بیندیشیم؟ در واقع سوال این است که زمانی که یک کاراکتر ۲۷ دریافت کردیم، از کجا متوجه شویم که این واقعا یک کلید escape بوده یا در ادامه ۲ کارکتر دیگر هم وارد خواهد شد و arrow key است؟

کار اولی که به ذهنم رسید، این بود که مقدار کمی صبر کنم و منتظر کاراکتر بعدی بمانم، اگر کلیدی وارد شد یعنی ۳ کارکتر بوده و همه همزمان وارد شده‌اند، اما اگر تا آن زمان چیزی وارد نشد یعنی کاربر فقط یک escape وارد کرده بوده است و باید همین را پردازش کنیم. برای اینکار یک ترد موازی برای صبر کردن در نظر گرفتم که ترد ورودی گرفتن را بعد از مدتی interrupt می‌کرد! متاسفانه این روش جواب نداد چون زمانی که روی System.in در حال read باشید و ترد اینتراپت شود، استریم ورودی دچار مشکل می‌شود و یک کاراکتر بعدی وارد نمی شود. ضمنا این روش یک باگ دارد و در صورتی که کاربر esc را نگه دارد هم اشتباه عمل می کند.

روش دومی که انجام دادم و نهایتا مشکل مرتفع شد، این بود که به متد read یک آرایه ۳ بایتی ورودی دهیم. در یک بار خواندن هرچند کاراکتر که وارد شود را می‌خواند و مقدار دهی می‌کند و نهایتا به عنوان مقدار برگشتی، تعداد المنت‌هایی که ورودی گرفته را بر می‌گرداند. حالا کار ساده‌است. در صورتی که مقدار برگشتی read برابر ۱ باشد یعنی یک تک کاراکتر وارد شده، اما در صورتی که ۳ باشد یعنی arrow key بوده و باید به مقدار اسکی کاراکتر سوم این ارایه توجه کرد.

مشکل دیگر بافر شدن مقدار ورودی در خود ترمینال بود، تا زمانی که یک newline ورودی داده نمی‌شد، استریم ورودی نمی‌توانست کاری از پیش ببرد. برای رفع این مشکل هم یک دستور shell را با کمک Runtime فراخوانی کردیم:

Runtime.getRuntime().exec(new String[]{"/bin/sh","-c","stty icanon </dev/tty"});


۲- مشکلات مستقل از سکو بودن

مستقل از سکو بودن جاوا مزیت‌های زیادی دارد و شکی در آن نیست ولی این سطح از انتزائی‌سازی، یک شمشیر دو لبه است. یک مشکل ساده که واقعا قابل حل نبود، گرفتن اندازه فعلی ترمینال بود. در جاوا هیچ راهی برای این کار پیدا نکردم. (البته با لایبرری jcurses و چیزهای مشابه امکان‌پذیره ولی به صورت خالص منظور است.) در آخر هم بیخیال شدم و اابعاد ترمینال را به صورت ثابت ۸۰*۲۴ در نظر گرفتم.

اما مشکل دیگر، نبود فایل اجرایی binary بود. کدام ادیتوری را دیدید که برای اجرا به ران‌تایم احتیاج داشته باشد، و بدتر از آن برای اجرای ادیتور مجبور باشید دستور زیر را بنویسید:

java -jar EditorName.jar myfile.txt

به همه اینجا، سربار جمع آوری زباله و مصرف رم زیاد برای بافر های نسبتا بزرگ هم اضافه کنید.


۳- جا به جا کردن کرسر و چاپ روی صفحه

با مطالبی که خوندم، به نظر می‌رسد ترمینال‌ها دو حالت اصلی داشته باشند، حالت چاپ متن عادی که به صورت معمولی در آن ها print می‌کنیم و حالت خام یا raw mode. در این حالت آزادی بیشتری وجود دارد مثلا هر کلیدی که کاربر وارد کند مستقیم روی صفجه چاپ نمی‌شود، بلکه هرجا که برنامه‌نویس بخواهد هرچیزی چاپ می‌شود. اما متاسفانه امکان فعال سازی این حالت در جاوا وجود نداشت.. پس راه کار چی بود؟

تنها دوستس ما در این زمینه ascii escape code ها بودند. اینها کد هایی هستند که به شما اجازه می‌دهند با ترمینالی که ansi را پشتیبانی کند (اکثر ترمینال‌ها) کارهای جالب انجام دهید. مثلا با رنگ‌های گوناگون چاپ کنید، صفحه را پاک کنید، یک خط را پاک کنید، و کرسر را جا به جا کنید. برای ادیتور هم بیشترین استفاده‌ی من همین اسکیپ‌کد ها بودند. با هر بار وارد کردن یک کلید، کل آن خط را پاک می‌کرده و مجدد چاپ می‌کنیم چرا که اثر آن حرف روی صفحه باقی مانده بود و به لحاظ بصری جالب نبود. همچنین برای اسکرول هم کد صفحه را پاک می‌کنیم و با یکی جا به جا کردن خط ها دوباره چاپ میکنیم. همچنین برای جا به جا کردن کرسر هم باید مختصات کرسر را با کد مخصوصی تولید و چاپ کنید تا کرسر جا به جا شود. پیاده‌سازی های مرتبط را در کلاس Cursor می‌توانید بررسی کنید.https://en.wikipedia.org/wiki/ANSI_escape_code


https://en.wikipedia.org/wiki/ANSI_escape_code


جمع بندی

با همه محدودیت‌ها و مواردی که اشاره شد، ادیتور مورد اشاره به مرحله کار کردن رسید و اینجانب درس مربوطه را پاس کردم. :)

اما به عنوان توصیه و یادگاری از من، برای هرکاری ابزارهای مناسب را استفاده کنید. جاوا در جا و محل استفاده خودش مثلا برنامه‌های سازمانی بسیار مناسب است و هیچ حرفی در آن نیست ولی برای برنامه‌های تحت ترمینال مخصوصا به صورت جاوای خالص و بدون لایبرری اصلا گزینه مناسبی نیست و بهتر است از ابزارهایی که برای اینکار مناسب‌تر هستند استفاده شود، مثلا تا جایی که اطلاع دارم به جز سی و سی‌پلاس‌پلاس، rust و golang و node js گزینه‌های مناسبی برای توسعه برنامه تحت ترمینال هستند.


برای مشاهده ادیتور مورد بحث و کار کردن با آن می‌توانید به صفحه گیتهاب پروژه مراجعه کنید.

همچنین یک فایل گزارش موجود است که تک تک کلاس ها و چالش‌های برنامه را توضیح داده است. کد نیز تا حد خوبی کامنت‌گذاری شده است.


https://github.com/rsharifnasab/sbu_vi_limited/releases